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

Интерфейс, контролы и API WebPart

Руководство для разработчиков: как строить UI с контролами, обрабатывать действия пользователя и работать с данными Portal — на сервере (C#) и в браузере (JavaScript).

См. также: стили PortalUI, манифест (CSS/JS в пакете), SDK (контракт IWebPart), API каталога.

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 браузера, но не должен быть единственным местом защиты.


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>""";
}

Кратко — самые частые классы:

КлассНазначение
btn btn--settings btn--smКнопка тулбара
btn btn--primary btn--smГлавное действие
btn btn--ghost btn--smВторостепенное / отмена
tbx__controlПоле ввода, <select>, <textarea>
form-error / form-successОшибка / успех
webpart-hint / webpart-emptyПодсказка / пусто
loading-textИндикатор загрузки

Подробный справочник с примерами, токенами темы, msg / badge / blk / tbl и антипаттернами: Стили и контролы PortalUI.

Свой layout — в assets/main.css с префиксом .webpart-…. Подключение: manifest.md.

Пикеры person / lookup: готовые контролы PortalPersonPicker и PortalLookupPicker (data-portal-person-picker / data-portal-lookup-picker) — chip+search, single/multi, автоинициализация после render, значение в hidden. Подробно: Кастомная форма §6.

Кнопки навигации (предыдущий/следующий период):

<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.

Для статической разметки (каркас формы, модалки, toolbar, карточки, карусель) удобнее вынести HTML в Templates/*.html и подключить как Embedded Resource в .csproj. Платформа принимает только итоговую строку WebPartResult.Html — способ сборки на ваше усмотрение.

Рекомендуемый гибридный подход:

ЧастьГде держатьКак собирать
Каркас формы, модалки, toolbarTemplates/*.htmlWebPartTemplate.Load + {{key}}
<option>, строки таблицы, циклыC#StringBuilder или string.Join
Ошибки, условные блокиC#if + WpHtml.Error(...)

Структура исходников пакета:

my-widget/
├── Templates/
│ ├── widget.html ← embedded в DLL
│ └── create-form.html
├── MyWidgetWebPart.cs
├── MyWidget.csproj ← <EmbeddedResource Include="Templates\*.html" />
├── manifest.json
└── dist/
├── main.css ← assets для браузера
└── main.js

Пример в C#SDK-хелпер WebPartTemplate из Portal.WebPart.Sdk:

private const string CreateFormTemplate = "Templates.create-form.html";
private static string RenderCreateFormModal(...)
{
var statusOptions = new StringBuilder();
foreach (var status in StatusChoices)
{
statusOptions.Append($"""<option value="{WpHtml.EscapeAttr(status)}">{WpHtml.Escape(status)}</option>""");
}
return WebPartTemplate.LoadAndApply(
typeof(MyWebPart).Assembly,
CreateFormTemplate,
new Dictionary<string, string>
{
["formError"] = string.IsNullOrEmpty(formError) ? "" : WpHtml.Error(formError),
["statusOptions"] = statusOptions.ToString(),
});
}

Имя ресурса — manifest name из сборки (обычно {RootNamespace}.Templates.create-form.html) или суффикс Templates.create-form.html, если он уникален.

Важно: значения плейсхолдеров экранируйте вручную через WpHtml.Escape / EscapeAttr до подстановки. HTML-файлы из manifest.json (styles, scripts) отдаются браузеру и не доступны C# при RenderAsync.

Шаблоны в примерах (packages/webpart-examples/, не входят в поставку):

ПакетФайлы в Templates/
taskscreate-form, toolbar, legend, detail-modal
meeting-room-bookingbooking-form, toolbar, legend, detail-modal
address-bookshell, employee-card
birthdayshero-header, empty-state, person-card
birthdays-widgetheader, carousel-shell, slide
bannerscarousel-shell, slide, empty-state
hello-widgetwidget
current-user-widgetguest, profile

Эталонный пример с формами и таймлайном: tasks.


В Portal есть три паттерна взаимодействия. Их можно комбинировать в одном WebPart.

Платформа (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-idwpRoomIddata.AsActionData().GetString("roomId")
data-wp-startwpStartform.GetString("start")
name="assignee_json"wpAssigneeJsonform.GetJson("assigneeJson")
data-wp-deltawpDeltaform.GetString("delta")

Префикс wp / snake_case / camelCase нормализует SDKсвой GetDataString копировать не нужно:

using Portal.WebPart.Sdk;
var form = data.AsActionData();
var roomId = form.GetGuid("roomId");
var start = form.GetString("start");
var isActive = form.GetBoolean("isActive");
var assignee = form.GetJson("assigneeJson"); // object/array или JSON-строка из hidden

Методы: GetString, GetBoolean, GetGuid, GetInt32, GetNumber, GetJson, TryGet.
Эквивалент: data.GetActionString("title"), data.GetActionJson("assigneeJson").

Обработчик действий на сервере:

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-*).

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

Платформа (csharpWebPartShell.js) при клике по data-wp-action сама собирает контролы с атрибутом name из ближайшей <form> или [data-wp-collect] и кладёт их в payload action (как wpTitle для name="title"). На сервере: data.AsActionData().GetString("title") / GetJson("assigneeJson").

  • Явные data-wp-* на кнопке не перезаписываются полями формы.
  • Опт-аут: data-wp-no-collect на кнопке или контейнере.
  • Для create/edit элементов предпочтителен PortalListItem.

Ручное копирование в dataset из bind (как в старых примерах meeting-room-booking) больше не обязательно для обычных полей.

ЗадачаПаттернПример
Смена даты, пагинацияdata-wp-actionmeeting-room-booking, tasks
Создание / правка элемента спискаdata-wp-action + автосбор формы + PortalListItemcustom-form-fields
Поиск по уже загруженным карточкамbind + JSON в HTMLaddress-book
Модалка просмотра (без записи)bind, без actionmeeting-room-booking

Серверный контекст создаётся для каждого вызова render / action. Права текущего пользователя проверяются при каждом обращении к Lists/Libraries/Nodes.

Объявление схемы, сценарии (в т.ч. список из другого узла) и все типы: Параметры WebPart.

// 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", "Заголовок");
// Список из другого узла: nodePicker (scope: subtree) + listPicker dependsOn — см. properties.md §3.2
// Текущий пользователь и узел страницы
var userId = ctx.User?.Id;
var displayName = ctx.User?.DisplayName ?? ctx.User?.Login;
var nodeId = ctx.NodeId; // Guid? — узел страницы/формы (= ctx.Host.NodeId)
// Контекст формы списка (без Property Pane itemId)
if (ctx.Host.IsListForm)
{
var item = await ctx.Host.GetOrNewListItemAsync(ct);
// ctx.Host.ListId, ItemRef, FormMode — см. SDK § IWebPartHost
}

Расширения: WebPartPropertiesExtensions в IWebPart.cs.
Host: SDK § IWebPartHost, кастомная форма.

Рекомендуемый путьPortalListItem по internal_name:

using Portal.Contracts.Lists;
// Метаданные списка
var list = await ctx.Lists.GetAsync(listId, ct);
var listTitle = list.GetProperty("title").GetString();
// Элементы (с опциональным viewId и limit)
var viewId = ctx.Properties.GetGuid("viewId");
Guid? view = viewId == Guid.Empty ? null : viewId;
var items = await ctx.Lists.GetListItemsAsync(listId, view, limit: 50, ct);
// Фильтрованная выборка (предпочтительно для больших списков)
var filtered = await ctx.Lists.QueryListItemsAsync(listId,
ListItemQueryBuilder.ForList(listId)
.WhereField("title", f => f.Eq("GlobalSettings"))
.Take(1)
.Build(), ct);
foreach (var row in items)
{
var itemTitle = row.GetString("title");
var status = row.GetString("status");
}
// Один элемент (itemRef — номер или guid)
var item = await ctx.Lists.GetListItemAsync(listId, "12", ct);
// Создание (проверка права add на узел)
var created = await ctx.Lists.NewListItemAsync(listId, ct);
created["title"] = "Новая задача";
created["status"] = "Новая";
created["start_at"] = "2026-07-06";
created["assigned_to"] = new Dictionary<string, object?>
{
["principalType"] = "user",
["id"] = userId.ToString(),
["display"] = displayName,
};
created["room"] = new Dictionary<string, object?>
{
["itemId"] = roomItemId.ToString(),
["display"] = "Переговорная 301",
};
await created.CreateAsync(ct);
// Обновить (merge — только изменённые поля)
item["status"] = "Готово";
await item.UpdateAsync(ct);

Низкоуровнево: GetFieldsAsync + GetItemAsync / CreateItemAsync с UUID-ключами в field_values. Форматы значений — API списков, значения полей.

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"
}
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);
}
var fields = await ctx.Lists.GetFieldsAsync(listId, ct);
var field = fields.First(f => f.GetProperty("internal_name").GetString() == "due_date");
// Сырое значение для FormatValue — из схемы/UUID или из PortalListItem:
var rawValue = item["due_date"]; // PortalListItem
var display = ctx.Fields.FormatValue(field, rawValue); // "06.07.2026 15:30" для datetime

Пример таблицы: встроенный ListViewWebPart.

3.6. Типичный поток «загрузка → рендер → действие»

Заголовок раздела «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.QueryListItemsAsync(config.ListId,
ListItemQueryBuilder.ForList(config.ListId)
.View(ctx.Properties.GetGuid("viewId") is var v && v != Guid.Empty ? v : null)
.Take(100)
.Build(), 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); // проверка доступа
return new() { ListId = listId };
}
catch (Exception ex)
{
return new() { Error = WpHtml.Error(ex.Message) };
}
}

Из 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.

Модуль 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,
filter: { logic: 'and', conditions: [{ column: 'title', operator: 'eq', value: 'A' }] },
q: 'поиск',
});
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);
// Права (node, list, library, page — без listId/libraryId)
const canEdit = await ctx.permissions.check('list', listId, 'edit');
// list_item / file: PortalApi.get('/permissions/check?...&listId=...')
// Полные примеры проверки и назначения — см. API: права доступа (раздел «Примеры из кода»)
// Форматирование
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 списков, значения полей, библиотек, узлов.

Допустимо в JSЛучше на сервере (C#)
Фильтрация/сортировка уже отрендеренных данныхПервичная загрузка данных с проверкой прав
Модалки просмотра, анимации, drag-and-drop UIСоздание записей с бизнес-валидацией
Дополнительные GET для динамической подгрузкиСкрытие данных, недоступных пользователю
Сбор полей формы перед data-wp-actionКонфликты бронирования, проверка дубликатов
PortalApi для SPA-подобных сценариев внутри bindЛюбая логика, которую нельзя доверять клиенту

Рекомендация: запись в списки (бронирование, задачи) — через HandleActionAsync + PortalListItem (NewListItemAsync / GetListItemAsyncCreateAsync / UpdateAsync). JS — для UX вокруг этого. См. Значения полей списков.

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 при серверном рендере.


МетодПутьОписание
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. Сквозной пример: кнопка «Обновить» с перезагрузкой списка

Заголовок раздела «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".


Пакет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)

  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. Сборкаscripts/pack-portalpart.sh / .ps1 из SDK, загрузка .portalpart, увеличение version при обновлении (cli.md).