Интерфейс, контролы и API WebPart
Интерфейс, контролы и API WebPart
Руководство для разработчиков: как строить UI с контролами, обрабатывать действия пользователя и работать с данными Portal — на сервере (C#) и в браузере (JavaScript).
См. также: манифест (CSS/JS в пакете), SDK (контракт IWebPart), API каталога.
Архитектура: гибридный SSR
Portal WebPart работает по модели гибридного серверного рендеринга:
Редактор страницы → properties в zones_content ↓csharpWebPartShell.js → POST /api/v1/webparts/invoke/{key}/render ↓C# IWebPart.RenderAsync → HTML ↓Браузер вставляет HTML + подключает CSS/JS из пакета ↓Пользователь кликает → POST /invoke/{key}/action (опционально) ↓C# IWebPart.HandleActionAsync → новый HTML ↓bind(container) из dist/main.js (опционально)| Слой | Где выполняется | Для чего |
|---|---|---|
| HTML | Сервер (C#) | Разметка, данные, формы, кнопки |
| CSS | Пакет .portalpart | Оформление, сетки, модалки |
| JS (bind) | Пакет .portalpart | Локальный UI без round-trip (поиск, модалки просмотра) |
| Actions | Сервер (C#) | Запись в списки, смена периода, валидация |
| Property Pane | Платформа | Настройки WebPart (listId, pageSize и т.д.) |
Важно: любые операции с данными, требующие проверки прав (чтение списков, создание элементов), должны выполняться на сервере через IWebPartContext. JavaScript в пакете имеет доступ к REST API браузера, но не должен быть единственным местом защиты.
1. Добавление интерфейса с контролами
1.1. Базовая разметка в RenderAsync
HTML формируется в C# как строка и возвращается в WebPartResult. Корневой контейнер — с уникальным префиксом классов:
public async Task<WebPartResult> RenderAsync(IWebPartContext ctx, CancellationToken ct){ var title = ctx.Properties.GetString("title", "Мой виджет"); var html = new StringBuilder(); html.Append("""<div class="webpart-my-widget">"""); html.Append($"""<h3 class="webpart-my-widget__title">{WpHtml.Escape(title)}</h3>"""); html.Append(""" <div class="webpart-my-widget__toolbar"> <button type="button" class="btn btn--settings btn--sm" data-wp-action="refresh"> <i class="fa-solid fa-rotate" aria-hidden="true"></i> <span class="btn__text">Обновить</span> </button> </div> <p class="webpart-my-widget__body">Содержимое виджета</p> """); html.Append("</div>"); return new WebPartResult(html.ToString());}Вспомогательный класс для экранирования (обязателен для защиты от XSS):
internal static class WpHtml{ public static string Escape(string? text) => System.Net.WebUtility.HtmlEncode(text ?? ""); public static string EscapeAttr(string? text) => Escape(text).Replace("\"", """, StringComparison.Ordinal); public static string Hint(string text) => $"""<p class="webpart-hint">{Escape(text)}</p>"""; public static string Error(string text) => $"""<p class="form-error">{Escape(text)}</p>""";}1.2. Стили портала и контролы
Используйте готовые классы Portal, чтобы виджет выглядел единообразно:
| Класс | Назначение |
|---|---|
btn btn--settings btn--sm | Кнопка |
tbx__control | Поле ввода, <select>, <textarea> |
form-error | Сообщение об ошибке |
webpart-hint | Подсказка (например, «Укажите список в настройках») |
loading-text | Индикатор загрузки (использует платформа) |
Собственные стили — в dist/main.css пакета с префиксом по корневому классу (.webpart-my-widget, .webpart-my-widget__toolbar). CSS-переменные портала: var(--color-border, #e5e7eb), var(--color-surface).
Подключение — через styles в manifest.json (см. manifest.md).
1.3. Типичные контролы
Кнопки навигации (предыдущий/следующий период):
<button type="button" class="btn btn--settings btn--sm" data-wp-action="changePeriod" data-wp-delta="prev" data-wp-date="2026-07-06" data-wp-mode="week"> ‹</button>Выбор даты — <input type="date"> с data-wp-action (срабатывает на change):
<input type="date" class="tbx__control" data-wp-action="changePeriod" data-wp-mode="week" data-wp-date="2026-07-06" value="2026-07-06">Переключатель режима (неделя/месяц):
<button type="button" data-wp-action="changeMode" data-wp-mode="month" data-wp-date="2026-07-06"> Месяц</button>Форма в модальном окне — поля без data-wp-action, кнопка отправки с action и начальными data-wp-*:
<form id="my-form"> <label>Название <input type="text" class="tbx__control" name="title" required> </label> <label>Статус <select class="tbx__control" name="status" id="my-status"> <option value="Новая" selected>Новая</option> <option value="В работе">В работе</option> </select> </label></form><button type="button" id="my-submit" data-wp-action="createItem" data-wp-date="2026-07-06"> Создать</button>Таблица / таймлайн — ячейки-кнопки для выбора слота:
<button type="button" class="webpart-my-widget__slot" data-wp-action="selectSlot" data-wp-room-id="{roomId}" data-wp-start="2026-07-06T10:00:00" data-wp-end="2026-07-06T10:30:00"></button>Полные примеры разметки: TasksWebPart.cs, MeetingRoomBookingWebPart.cs.
2. Взаимодействие с контролами
В Portal есть три паттерна взаимодействия. Их можно комбинировать в одном WebPart.
2.1. Серверные действия: data-wp-action
Платформа (csharpWebPartShell.js) автоматически привязывает обработчики ко всем элементам с атрибутом data-wp-action:
- Пользователь кликает (или меняет
input[type="date"]/selectсdata-wp-action). - Браузер собирает все
data-wp-*атрибуты элемента в объектdata. - Отправляется
POST /api/v1/webparts/invoke/{manifestKey}/actionс телом:
{ "action": "changePeriod", "data": { "wpDelta": "prev", "wpDate": "2026-07-06", "wpMode": "week" }, "properties": { "listId": "..." }, "nodeId": "uuid-узла-страницы"}- Сервер вызывает
HandleActionAsyncи возвращает новый HTML целиком. - Контейнер WebPart заменяется; снова вызываются
_bindActions()иbind(container).
Правила именования атрибутов:
| В HTML | В data на сервере | Как читать в C# |
|---|---|---|
data-wp-action="book" | action = "book" | параметр action |
data-wp-room-id | wpRoomId | GetDataString(data, "roomId") |
data-wp-start | wpStart | GetDataString(data, "start") |
data-wp-delta | wpDelta | GetDataString(data, "delta") |
Префикс wp- в HTML снимается при передаче; в C# удобно искать по короткому имени (roomId, start, delta).
Универсальный хелпер (из примеров tasks и meeting-room-booking):
private static string GetDataString(JsonElement data, string key){ if (data.ValueKind != JsonValueKind.Object) return ""; var keyNorm = key.Replace("-", "", StringComparison.Ordinal).ToLowerInvariant(); foreach (var prop in data.EnumerateObject()) { var normalized = prop.Name.Replace("-", "", StringComparison.Ordinal).ToLowerInvariant(); if (normalized.StartsWith("wp", StringComparison.Ordinal)) normalized = normalized[2..]; if (normalized == keyNorm) return prop.Value.ValueKind == JsonValueKind.String ? prop.Value.GetString() ?? "" : prop.Value.ToString(); } return "";}Обработчик действий на сервере:
public async Task<WebPartResult> HandleActionAsync( string action, JsonElement data, IWebPartContext ctx, CancellationToken ct){ return action switch { "changePeriod" => await HandleChangePeriodAsync(ctx, data, ct), "changeMode" => await HandleChangeModeAsync(ctx, data, ct), "selectSlot" => await HandleSelectSlotAsync(ctx, data, ct), "createItem" => await HandleCreateItemAsync(ctx, data, ct), "cancelSelect" => await RenderMainViewAsync(ctx, ct), _ => new WebPartResult("", Error: "Неизвестное действие"), };}Состояние UI (выбранная дата, открытая форма, режим просмотра) хранится в серверном рендере: при каждом action C# заново строит HTML с нужными модалками, значениями полей и сообщениями об ошибках. Отдельного клиентского state store нет.
Особенности input[type="date"]: при change платформа кладёт выбранное значение в data.wpTarget (в дополнение к остальным data-wp-*).
2.2. Клиентская логика без сервера: хук bind
Для чисто клиентского поведения (фильтрация, раскрытие дерева, модалка «только просмотр») используйте scripts в манифесте и регистрацию в PortalWebPartClients:
(function () { const MANIFEST_KEY = 'demo.address-book'; // = id в manifest.json
function bind(root) { if (!root || root.dataset.uiBound === '1') return; root.dataset.uiBound = '1';
const searchInput = root.querySelector('.webpart-address-book__search-input'); searchInput?.addEventListener('input', () => applyFilters(root));
root.querySelectorAll('.webpart-address-book__tree-btn').forEach((btn) => { btn.addEventListener('click', () => { // переключение активного подразделения, фильтрация карточек applyFilters(root); }); }); }
window.PortalWebPartClients = window.PortalWebPartClients || {}; window.PortalWebPartClients[MANIFEST_KEY] = { bind(container) { const roots = container.classList?.contains('webpart-address-book') ? [container] : Array.from(container.querySelectorAll('.webpart-address-book')); roots.forEach(bind); }, };})();bind вызывается после каждого SSR-рендера и action. Защита от повторной привязки: root.dataset.uiBound = '1'.
Пример: address-book/dist/main.js — поиск и дерево подразделений без запросов к серверу. Данные для фильтрации сервер встраивает в JSON:
<script type="application/json" class="webpart-address-book__data" data-address-book-employees> [{"id":"...","fullName":"..."}]</script>2.3. Гибрид: форма на сервере + JS перед отправкой
Когда значения полей формы нужно передать в action, а поля не имеют data-wp-action, JS перед кликом копирует их в dataset кнопки submit (обработчик click с capture: true):
function bindFormSubmit(root) { const btn = root.querySelector('#webpart-room-booking-submit'); const endSel = root.querySelector('#webpart-room-booking-end'); const titleInp = root.querySelector('#webpart-room-booking-form [name=title]'); if (!btn || btn.dataset.formBound === '1') return; btn.dataset.formBound = '1'; btn.addEventListener('click', () => { btn.dataset.wpEnd = endSel.value; if (titleInp?.value) btn.dataset.wpTitle = titleInp.value; }, true); // capture: true — до обработчика платформы}На сервере:
var end = DateTime.TryParse(GetDataString(data, "end"), null, DateTimeStyles.RoundtripKind, out var parsedEnd) ? parsedEnd : defaultEnd;var title = GetDataString(data, "title").Trim();Пример: meeting-room-booking/dist/main.js.
2.4. Сравнение паттернов
| Задача | Паттерн | Пример |
|---|---|---|
| Смена даты, пагинация | data-wp-action | meeting-room-booking, tasks |
| Создание записи в списке | data-wp-action + CreateItemAsync | tasks, meeting-room-booking |
| Поиск по уже загруженным карточкам | bind + JSON в HTML | address-book |
| Модалка просмотра (без записи) | bind, без action | meeting-room-booking |
| Сложная форма с select/input | bind (сбор полей) + action | meeting-room-booking |
3. API на сервере (C# / IWebPartContext)
Серверный контекст создаётся для каждого вызова render / action. Права текущего пользователя проверяются при каждом обращении к Lists/Libraries/Nodes.
3.1. Свойства и пользователь
// Property Pane (manifest.json → properties)var listId = ctx.Properties.GetGuid("listId");var pageSize = ctx.Properties.GetInt32("pageSize", 10);var showTitle = ctx.Properties.GetBoolean("showTitle", true);var title = ctx.Properties.GetString("title", "Заголовок");
// Текущий пользователь и узел страницыvar userId = ctx.User?.Id;var displayName = ctx.User?.DisplayName ?? ctx.User?.Login;var nodeId = ctx.NodeId; // Guid? — узел, на котором открыта страницаРасширения: WebPartPropertiesExtensions в IWebPart.cs.
3.2. Списки (ctx.Lists)
// Метаданные спискаvar list = await ctx.Lists.GetAsync(listId, ct);var listTitle = list.GetProperty("title").GetString();
// Схема полейvar fields = await ctx.Lists.GetFieldsAsync(listId, ct);foreach (var field in fields){ var internalName = field.GetProperty("internal_name").GetString(); var fieldType = field.GetProperty("field_type").GetString(); var fieldId = field.GetProperty("id").GetString();}
// Элементы (с опциональным viewId и limit)var viewId = ctx.Properties.GetGuid("viewId");Guid? view = viewId == Guid.Empty ? null : viewId;var items = await ctx.Lists.GetItemsAsync(listId, view, limit: 50, ct: ct);
foreach (var item in items){ var itemId = item.GetProperty("id").GetString(); // field_values — объект с ключами = UUID полей if (item.TryGetProperty("field_values", out var fv) && fv.TryGetProperty(titleFieldId, out var titleEl)) { var itemTitle = titleEl.GetString(); }}
// Один элементvar item = await ctx.Lists.GetItemAsync(listId, itemId, ct);
// Создание элемента (проверка права add на узел)var fieldValues = new Dictionary<string, object?>{ [titleFieldId] = "Новая задача", [statusFieldId] = "Новая", [startFieldId] = "2026-07-06", [assigneeFieldId] = new Dictionary<string, object?> { ["principalType"] = "user", ["id"] = userId.ToString(), ["display"] = displayName, }, [lookupFieldId] = new Dictionary<string, object?> { ["itemId"] = roomItemId.ToString(), ["display"] = "Переговорная 301", },};var created = await ctx.Lists.CreateItemAsync(listId, fieldValues, ct);Форматы значений полей — как в API списков. На сервере в SDK доступны чтение и создание; обновление и удаление элементов через SDK WebPart пока не экспонированы — используйте создание или вынесите логику в отдельный сервис.
3.3. Библиотеки (ctx.Libraries)
var library = await ctx.Libraries.GetAsync(libraryId, ct);var files = await ctx.Libraries.GetFilesAsync(libraryId, limit: 20, ct: ct);
foreach (var file in files){ var fileId = Guid.Parse(file.GetProperty("id").GetString()!); var fileName = file.GetProperty("name").GetString(); var downloadHref = ctx.Libraries.DownloadUrl(fileId); // downloadHref = "/api/v1/files/{fileId}/download"}3.4. Узлы и маршруты
if (ctx.NodeId is Guid nodeId){ var node = await ctx.Nodes.GetAsync(nodeId, ct); var children = await ctx.Nodes.GetChildrenAsync(nodeId, ct);
foreach (var child in children) { var childId = Guid.Parse(child.GetProperty("id").GetString()!); var href = await ctx.Routes.NodeHrefAsync(childId, ct); // href — человеко-читаемый путь, например "/otdel/it" }
var itemHref = await ctx.Routes.ListItemHrefAsync(nodeId, listId, itemId, ct);}3.5. Форматирование полей
var fields = await ctx.Lists.GetFieldsAsync(listId, ct);var field = fields.First(f => f.GetProperty("internal_name").GetString() == "due_date");var rawValue = item.GetProperty("field_values").GetProperty(field.GetProperty("id").GetString()!);var display = ctx.Fields.FormatValue(field, rawValue); // "06.07.2026 15:30" для datetimeПример таблицы: встроенный ListViewWebPart.
3.6. Типичный поток «загрузка → рендер → действие»
public async Task<WebPartResult> RenderAsync(IWebPartContext ctx, CancellationToken ct){ var config = await LoadConfigAsync(ctx, ct); if (config.Error is not null) return new WebPartResult(config.Error);
var items = await ctx.Lists.GetItemsAsync(config.ListId, limit: 100, ct: ct); return new WebPartResult(BuildHtml(items, pending: null, error: null));}
private async Task<WebPartConfig> LoadConfigAsync(IWebPartContext ctx, CancellationToken ct){ var listId = ctx.Properties.GetGuid("listId"); if (listId == Guid.Empty) return new() { Error = WpHtml.Hint("Выберите список в настройках WebPart") };
try { await ctx.Lists.GetAsync(listId, ct); // проверка доступа var fields = await ctx.Lists.GetFieldsAsync(listId, ct); return new() { ListId = listId, TitleFieldId = FindFieldId(fields, "title") }; } catch (Exception ex) { return new() { Error = WpHtml.Error(ex.Message) }; }}4. API в JavaScript (браузер)
4.1. Эндпоинты самого WebPart
Из JS пакета или консоли браузера доступны вызовы через PortalApi (обёртка над /api/v1):
// Повторный рендер (обычно делает платформа сама)const res = await PortalApi.post('/webparts/invoke/contoso.my-widget/render', { properties: { listId: '...', pageSize: 10 }, nodeId: 'uuid-узла',});const html = res.data.html;const error = res.data.error;
// Действие (аналог клика по data-wp-action)const actionRes = await PortalApi.post('/webparts/invoke/contoso.my-widget/action', { action: 'refresh', data: { wpDate: '2026-07-06' }, properties: { listId: '...' }, nodeId: 'uuid-узла',});Контекст страницы для picker-ов в редакторе: GET /api/v1/webparts/context?nodeId={uuid}.
Ассеты пакета: GET /api/v1/webparts/assets/{manifestKey}/dist/main.v1.0.0.js.
4.2. PortalWebPartContext — удобная обёртка
Модуль portalContext.js предоставляет клиентский аналог серверного API:
const ctx = PortalWebPartContext.create({ nodeId: 'uuid-узла-страницы', user: window.currentUser, // опционально});
// Узлыconst node = await ctx.nodes.get(nodeId);const children = await ctx.nodes.getChildren(nodeId);const resolved = await ctx.routes.resolvePath('/otdel/it');
// Спискиconst list = await ctx.lists.get(listId);const fields = await ctx.lists.getFields(listId);const items = await ctx.lists.getItems(listId, { viewId, top: 50 });const item = await ctx.lists.getItem(listId, itemId);const views = await ctx.lists.getViews(listId);
// Библиотекиconst library = await ctx.libraries.get(libraryId);const files = await ctx.libraries.getFiles(libraryId, { limit: 20 });const downloadUrl = ctx.libraries.downloadUrl(fileId);
// Праваconst canEdit = await ctx.permissions.check('list', listId, 'edit');
// Форматированиеconst text = ctx.fields.formatValue(field, value);Прямой доступ к REST (создание, обновление, удаление):
// Создать элемент (нужно право add)await PortalApi.post(`/lists/${listId}/items`, { fieldValues: { 'field-uuid-title': 'Заголовок', 'field-uuid-status': 'Новая', },});
// Обновить элемент (право edit)await PortalApi.patch(`/lists/${listId}/items/${itemId}`, { fieldValues: { 'field-uuid-status': 'Готово' },});
// Удалить (право delete)await PortalApi.delete(`/lists/${listId}/items/${itemId}`);Полный перечень методов — в API списков, библиотек, узлов.
4.3. Что разумно делать в JS
| Допустимо в JS | Лучше на сервере (C#) |
|---|---|
| Фильтрация/сортировка уже отрендеренных данных | Первичная загрузка данных с проверкой прав |
| Модалки просмотра, анимации, drag-and-drop UI | Создание записей с бизнес-валидацией |
| Дополнительные GET для динамической подгрузки | Скрытие данных, недоступных пользователю |
Сбор полей формы перед data-wp-action | Конфликты бронирования, проверка дубликатов |
PortalApi для SPA-подобных сценариев внутри bind | Любая логика, которую нельзя доверять клиенту |
Рекомендация: запись в списки (бронирование, задачи) — через HandleActionAsync + ctx.Lists.CreateItemAsync. JS — для UX вокруг этого.
4.4. Пример: динамическая подгрузка в bind
async function loadRecentComments(root, ctx, listId) { const container = root.querySelector('[data-recent-comments]'); if (!container || container.dataset.loaded === '1') return; container.dataset.loaded = '1'; container.textContent = 'Загрузка…';
try { const items = await ctx.lists.getItems(listId, { top: 5 }); container.innerHTML = items.map((i) => `<li>${escapeHtml(i.field_values?.[titleFieldId] ?? '—')}</li>` ).join(''); } catch (e) { container.textContent = 'Не удалось загрузить'; }}
function bind(root) { // ... const ctx = PortalWebPartContext.create({ nodeId: root.dataset.nodeId }); loadRecentComments(root, ctx, root.dataset.listId);}nodeId и listId можно передать в HTML как data-node-id / data-list-id при серверном рендере.
5. HTTP API WebPart (справочник)
| Метод | Путь | Описание |
|---|---|---|
| POST | /api/v1/webparts/invoke/{key}/render | SSR: { properties, nodeId } → { html, error } |
| POST | /api/v1/webparts/invoke/{key}/action | Action: { action, data, properties, nodeId } → { html, error } |
| GET | /api/v1/webparts/context?nodeId= | Списки/библиотеки узла для Property Pane |
| GET | /api/v1/webparts/definitions | Каталог WebPart |
| GET | /api/v1/webparts/assets/{key}/{path} | CSS/JS из пакета |
Подробнее: api/webparts.md.
6. Сквозной пример: кнопка «Обновить» с перезагрузкой списка
C# — рендер:
public async Task<WebPartResult> RenderAsync(IWebPartContext ctx, CancellationToken ct){ var listId = ctx.Properties.GetGuid("listId"); if (listId == Guid.Empty) return new WebPartResult(WpHtml.Hint("Выберите список"));
var items = await ctx.Lists.GetItemsAsync(listId, limit: 5, ct: ct); var sb = new StringBuilder("""<div class="webpart-simple-list">"""); sb.Append("""<button type="button" class="btn btn--settings btn--sm" data-wp-action="refresh">Обновить</button><ul>"""); foreach (var item in items) sb.Append($"""<li>{WpHtml.Escape(item.GetProperty("id").GetString())}</li>"""); sb.Append("</ul></div>"); return new WebPartResult(sb.ToString());}
public Task<WebPartResult> HandleActionAsync(string action, JsonElement data, IWebPartContext ctx, CancellationToken ct) => action == "refresh" ? RenderAsync(ctx, ct) : Task.FromResult(new WebPartResult("", Error: "Unknown"));Никакого JS не требуется — платформа сама обрабатывает data-wp-action="refresh".
7. Примеры в репозитории
| Пакет | UI | Actions | JS | API |
|---|---|---|---|---|
| hello-widget | Статичный блок | — | — | Properties |
| current-user-widget | Имя пользователя | — | — | ctx.User |
| meeting-room-booking | Таймлайн, форма | changeDate, book, … | Модалки, форма | Lists CRUD |
| tasks | Таймлайн задач | createTask, changePeriod, … | — (только shell) | Lists |
| address-book | Дерево + карточки | — | Поиск, фильтр | Lists (SSR) |
8. Чеклист разработчика
- manifest.json —
id,entry,entryType,styles, при интерактиве —scripts. - Корневой класс — уникальный префикс (
.webpart-…). - Экранирование — все пользовательские строки через
HtmlEncode. - Кнопки/навигация —
data-wp-action+data-wp-*для параметров. - Формы — серверный рендер полей; submit через action; при необходимости JS собирает значения в
dataset. - Данные —
ctx.Lists/Libraries/Nodesв C#; JS — только для UX или доп. GET. - bind —
PortalWebPartClients[manifestKey]; защита от двойной привязки. - Сборка —
./build.sh, загрузка.portalpart, увеличениеversionпри обновлении (cli.md).