Перейти к содержимому

Интерфейс, контролы и 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("\"", "&quot;", 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:

  1. Пользователь кликает (или меняет input[type="date"] / select с data-wp-action).
  2. Браузер собирает все data-wp-* атрибуты элемента в объект data.
  3. Отправляется POST /api/v1/webparts/invoke/{manifestKey}/action с телом:
{
"action": "changePeriod",
"data": {
"wpDelta": "prev",
"wpDate": "2026-07-06",
"wpMode": "week"
},
"properties": { "listId": "..." },
"nodeId": "uuid-узла-страницы"
}
  1. Сервер вызывает HandleActionAsync и возвращает новый HTML целиком.
  2. Контейнер WebPart заменяется; снова вызываются _bindActions() и bind(container).

Правила именования атрибутов:

В HTMLВ data на сервереКак читать в C#
data-wp-action="book"action = "book"параметр action
data-wp-room-idwpRoomIdGetDataString(data, "roomId")
data-wp-startwpStartGetDataString(data, "start")
data-wp-deltawpDeltaGetDataString(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-actionmeeting-room-booking, tasks
Создание записи в спискеdata-wp-action + CreateItemAsynctasks, meeting-room-booking
Поиск по уже загруженным карточкамbind + JSON в HTMLaddress-book
Модалка просмотра (без записи)bind, без actionmeeting-room-booking
Сложная форма с select/inputbind (сбор полей) + actionmeeting-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}/renderSSR: { properties, nodeId }{ html, error }
POST/api/v1/webparts/invoke/{key}/actionAction: { 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. Примеры в репозитории

ПакетUIActionsJSAPI
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. Чеклист разработчика

  1. manifest.jsonid, entry, entryType, styles, при интерактиве — scripts.
  2. Корневой класс — уникальный префикс (.webpart-…).
  3. Экранирование — все пользовательские строки через HtmlEncode.
  4. Кнопки/навигацияdata-wp-action + data-wp-* для параметров.
  5. Формы — серверный рендер полей; submit через action; при необходимости JS собирает значения в dataset.
  6. Данныеctx.Lists / Libraries / Nodes в C#; JS — только для UX или доп. GET.
  7. bindPortalWebPartClients[manifestKey]; защита от двойной привязки.
  8. Сборка./build.sh, загрузка .portalpart, увеличение version при обновлении (cli.md).