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

Кастомная форма: все типы полей

Как сделать WebPart с формой создания / редактирования элемента списка: загрузить значения всех типов полей и сохранить их обратно.

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

  1. ctx.Host — текущий список / элемент / режим формы (платформа передаёт с URL формы).
  2. PortalListItemitem["title"], CreateAsync / UpdateAsync.

Низкоуровневый UUID-путь — в конце страницы.

Справочник форматов значений: Значения полей списков.
Контекст хоста и API: SDK WebPart.
Параметры WebPart (если список задаёте вручную): Параметры.
Контролы и data-wp-action: Интерфейс и API.
Классы полей и кнопок: Стили PortalUI.


  1. WebPart на форме списка (создание / просмотр / правка) — listId и номер элемента берутся из ctx.Host, не из Property Pane.
  2. Форма со всеми типами полей Portal.
  3. Создание и обновление через PortalListItemбез ручного словаря UUID.

Типы полей: text, multiline_text, html_text, choice, number, date, datetime, boolean, lookup, person, link.
Вложения — отдельный API /items/{id}/attachments.


  1. Создайте пакет из Extension SDK:
Окно терминала
dotnet new portal-webpart -n DemoForm -o ./demo-form
  1. В manifest.json для формы списка не нужен itemId. Список тоже обычно не нужен в панели — он уже в ctx.Host.ListId. Property Pane оставляйте пустым или только под опции UI:
{
"properties": {}
}

Если WebPart стоит на обычной странице и должна работать с выбранным списком — добавьте listPicker (Параметры). Номер элемента с панели для форм не задавайте: на форме /items/12/edit платформа сама кладёт 12 в ctx.Host.ItemRef.

  1. Установите .portalpart. Разместите на форме списка (Настройки списка → Формы → Создание / Просмотр / Редактирование). При выключенной стандартной форме сохранение делает ваша WebPart.

На форме списка платформа передаёт в invoke:

ПолеПримерКогда
KindListFormформа списка
ListIdGUID спискавсегда на форме
ItemRef"12"view / edit
FormModeNew / View / Editрежим формы
NodeIdGUID узлаузел списка
using Portal.Contracts.Lists;
using Portal.WebPart.Sdk;
// Ссылки (без запросов)
var listId = ctx.Host.ListId; // Guid?
var itemRef = ctx.Host.ItemRef; // "12" или null на создании
var isNew = ctx.Host.IsNewForm;
// Загрузка с проверкой прав
var listJson = await ctx.Host.GetListAsync(ct);
var item = await ctx.Host.GetOrNewListItemAsync(ct); // edit/view → элемент; new → пустой PortalListItem
// Эквивалентно:
// item = ctx.Host.IsNewForm
// ? await ctx.Lists.NewListItemAsync(ctx.Host.ListId!.Value, ct)
// : await ctx.Host.GetListItemAsync(ct);

На обычной странице: Kind = Page, доступны NodeId / PageId (GetNodeAsync).
Для файла библиотеки (если host передан): LibraryId / FileRef, GetLibraryAsync / GetFileAsync.

Подробная таблица: SDK § IWebPartHost.


using Portal.Contracts.Lists;
using Portal.WebPart.Sdk;
// --- На форме списка (рекомендуется) ---
var item = await ctx.Host.GetOrNewListItemAsync(ct);
var title = item.GetString("title"); // или (string?)item["title"]
var status = item.GetString("status");
var due = item.GetDate("due"); // "YYYY-MM-DD"
var start = item.GetDateTimeLocal("start_at");
var active = item.GetBoolean("is_active");
var room = item.GetLookup("room"); // .ItemId, .Display
var people = item.GetPerson("assignee"); // список PortalPersonValue
var link = item.GetLink("docs_link"); // .Url, .Description
item["status"] = "Готово";
item["due"] = "2026-07-15";
if (item.IsNew)
await item.CreateAsync(ct);
else
await item.UpdateAsync(ct); // merge: только изменённые поля
// --- Явно по listId + номеру (виджет на странице, не форма) ---
var listId = ctx.Properties.GetGuid("listId");
var loaded = await ctx.Lists.GetListItemAsync(listId, "12", ct);
var created = await ctx.Lists.NewListItemAsync(listId, ct);
created["title"] = "Новая задача";
created["assignee"] = new Dictionary<string, object?>
{
["principalType"] = "user",
["id"] = ctx.User!.Id.ToString(),
["display"] = ctx.User.DisplayName ?? ctx.User.Login,
};
await created.CreateAsync(ct);
// --- Выборка + LINQ ---
var items = await ctx.Lists.QueryListItemsAsync(
listId,
ListItemQueryBuilder.ForList(listId)
.WhereField("status", f => f.Eq("Новая"))
.Take(50)
.Build(),
ct);
var open = items.Where(i => i.GetString("priority") == "Высокий");

Все value-типы — records в Portal.Contracts.Lists.

ВыражениеТипПримечание
ctx.Host.GetOrNewListItemAsyncPortalListItemформа списка: load или new
item / createdPortalListItemрезультат GetListItemAsync / NewListItemAsync
itemsIReadOnlyList<PortalListItem>QueryListItemsAsync / GetListItemsAsync
item["title"]object?индексатор; для строки: (string?)item["title"]
GetStringstring?text, multiline, choice и др. как строка
GetDatestring?поле dateYYYY-MM-DD (не DateTime)
GetDateTimeLocalDateTime?поле datetime, локальное время процесса
GetBooleanboolнет значения → false; nullable: TryGetBooleanbool?
GetNumberdouble?числовые поля
GetLookupPortalLookupValue?.ItemId, .Display, .ItemGuid
GetPersonIReadOnlyList<PortalPersonValue>у каждого .PrincipalType, .Id, .Display
GetLinkPortalLinkValue?.Url, .Description

Ключи — internal_name полей (title, status, …). Сервер принимает их при записи; в ответах API по-прежнему UUID, но PortalListItem скрывает это.

Тот же API есть в Event Receiver и Timer Job SDK (api.Lists.GetListItemAsync / NewListItemAsync).


Поля с атрибутом name (= internal_name). Кнопка с data-wp-action.
Платформа сама собирает значения из ближайшей <form> (или [data-wp-collect]) в payload action — отдельный main.js с копированием в dataset не нужен.

<form class="webpart-demo-form">
<label>Название *
<input class="tbx__control" name="title" type="text" value="{{title}}" required>
</label>
<label>Описание
<textarea class="tbx__control" name="description">{{description}}</textarea>
</label>
<label>HTML
<textarea class="tbx__control" name="body_html">{{bodyHtml}}</textarea>
</label>
<label>Статус
<select class="tbx__control" name="status">{{statusOptions}}</select>
</label>
<label>Сумма
<input class="tbx__control" name="amount" type="number" step="any" value="{{amount}}">
</label>
<label>Срок
<input class="tbx__control" name="due" type="date" value="{{due}}">
</label>
<label>Начало
<input class="tbx__control" name="start_at" type="datetime-local" value="{{startAt}}">
</label>
<label>
<input name="is_active" type="checkbox" {{isActiveChecked}}> Активно
</label>
<!-- lookup: готовый PortalLookupPicker (§6) -->
<label>Переговорная
<div data-portal-lookup-picker
data-name="room_json"
data-list-id="{{listId}}"
data-target-list-id="{{roomTargetListId}}"
data-lookup-field-id="{{roomLookupFieldId}}"
data-allow-multiple="{{roomAllowMultiple}}">
<script type="application/json" data-portal-lookup-value>{{roomJson}}</script>
</div>
</label>
<!-- person: готовый PortalPersonPicker (§6) — платформа сама вызывает bindAll после render -->
<label>Исполнитель
<div data-portal-person-picker
data-name="assignee_json"
data-list-id="{{listId}}"
data-field-id="{{assigneeFieldId}}"
data-allow-multiple="true"
data-allow-users="true"
data-allow-groups="true">
<script type="application/json" data-portal-person-value>{{assigneeJson}}</script>
</div>
</label>
<label>URL <input class="tbx__control" name="link_url" type="url" value="{{linkUrl}}"></label>
<label>Подпись <input class="tbx__control" name="link_desc" type="text" value="{{linkDesc}}"></label>
<button type="button" data-wp-action="save" data-wp-item-id="{{itemId}}">
Сохранить
</button>
</form>

Опт-аут автосбора: data-wp-no-collect на кнопке или контейнере. Явные data-wp-* на кнопке имеют приоритет над полями формы.

Имена snake_case в name попадают в action как wpBodyHtml / wpStartAt. Читайте через SDK: data.AsActionData().GetString("bodyHtml") — префикс wp и snake_case/camelCase нормализуются.


if (!ctx.Host.HasList)
{
return new WebPartResult("", Error: "WebPart должна стоять на форме списка");
}
var item = await ctx.Host.GetOrNewListItemAsync(ct);
var itemId = item.IsNew ? "" : (item.ItemRef ?? item.Id.ToString());
var title = item.GetString("title") ?? "";
var description = item.GetString("description") ?? "";
var bodyHtml = item.GetString("body_html") ?? "";
var status = item.GetString("status") ?? "";
var amount = item.GetNumber("amount")?.ToString(CultureInfo.InvariantCulture) ?? "";
var due = item.GetDate("due") ?? "";
var startAt = item.GetDateTimeLocal("start_at")?.ToString("yyyy-MM-dd'T'HH:mm") ?? "";
var isActiveChecked = item.GetBoolean("is_active") ? "checked" : "";
var listId = ctx.Host.ListId!.Value.ToString();
// settings lookup/person — из GetFieldsAsync (Schema хранит только id/type)
var fields = await ctx.Lists.GetFieldsAsync(ctx.Host.ListId!.Value, ct);
var roomMeta = fields.First(f =>
string.Equals(f.GetProperty("internal_name").GetString(), "room", StringComparison.OrdinalIgnoreCase));
var roomSettings = roomMeta.TryGetProperty("settings", out var rs) ? rs : default;
var roomTargetListId = roomSettings.TryGetProperty("lookupListId", out var tl) ? tl.GetString() ?? "" : "";
var roomLookupFieldId = roomSettings.TryGetProperty("lookupFieldId", out var lf) ? lf.GetString() ?? "" : "";
var roomAllowMultiple = roomSettings.ValueKind == JsonValueKind.Object
&& roomSettings.TryGetProperty("allowMultiple", out var ram)
&& ram.ValueKind == JsonValueKind.True
? "true" : "false";
// GetLookup — один/первый; GetLookups — все (массив при allowMultiple)
var roomJson = roomAllowMultiple == "true"
? System.Text.Json.JsonSerializer.Serialize(item.GetLookups("room"))
: System.Text.Json.JsonSerializer.Serialize(item.GetLookup("room"));
var assigneeFieldId = item.Schema.GetFieldId("assignee")
?? throw new InvalidOperationException("Нет поля assignee");
// JSON как в поле person; внутри <script type="application/json"> — сырой JSON (не HtmlEncode)
var assigneeJson = System.Text.Json.JsonSerializer.Serialize(item.GetPerson("assignee"));
var link = item.GetLink("docs_link");
var linkUrl = link?.Url ?? "";
var linkDesc = link?.Description ?? "";
ТипМетод
text / multiline / choice / htmlGetString
numberGetNumber
dateGetDateYYYY-MM-DD
datetimeGetDateTimeLocal → для datetime-local
booleanGetBoolean
lookupGetLookup (один/первый) / GetLookups (все; multi при settings.allowMultiple: true)
personGetPerson (список; при allowMultiple: false обычно один элемент)
linkGetLink

public async Task<WebPartResult> HandleActionAsync(
string action, JsonElement data, IWebPartContext ctx, CancellationToken ct)
{
if (action != "save") return new WebPartResult("", Error: "Неизвестное действие");
if (!ctx.Host.HasList)
return new WebPartResult("", Error: "WebPart должна стоять на форме списка");
try
{
var form = data.AsActionData();
// Host знает режим формы и ItemRef; data-wp-item-id — запасной путь после редиректа
var itemId = form.GetString("itemId");
PortalListItem item = !string.IsNullOrEmpty(itemId)
? await ctx.Lists.GetListItemAsync(ctx.Host.ListId!.Value, itemId, ct)
: await ctx.Host.GetOrNewListItemAsync(ct);
ApplyFormData(item, form);
if (item.IsNew)
await item.CreateAsync(ct);
else
await item.UpdateAsync(ct);
return await RenderAsync(ctx, ct);
}
catch (Exception ex)
{
return new WebPartResult($"<p class=\"form-error\">{System.Net.WebUtility.HtmlEncode(ex.Message)}</p>");
}
}
static void ApplyFormData(PortalListItem item, WebPartActionData form)
{
string S(string key) => form.GetString(key).Trim();
item["title"] = S("title");
item["description"] = EmptyToNull(S("description"));
item["body_html"] = EmptyToNull(S("bodyHtml"));
item["status"] = S("status");
item["amount"] = form.GetNumber("amount");
item["due"] = EmptyToNull(S("due"));
var startAt = S("startAt");
if (string.IsNullOrEmpty(startAt))
item["start_at"] = null;
else if (DateTime.TryParse(startAt, null, DateTimeStyles.AssumeLocal, out var localDt))
item["start_at"] = TimeZoneInfo.ConvertTimeToUtc(localDt).ToString("O");
item["is_active"] = form.GetBoolean("isActive");
// lookup / person: hidden JSON от PortalLookupPicker / PortalPersonPicker
item["room"] = form.GetJson("roomJson");
item["assignee"] = form.GetJson("assigneeJson");
var url = S("linkUrl");
item["docs_link"] = string.IsNullOrEmpty(url)
? null
: new Dictionary<string, object?> { ["url"] = url, ["description"] = S("linkDesc") };
}
static string? EmptyToNull(string s) => string.IsNullOrEmpty(s) ? null : s;

Подставьте свои internal_name. Форматы сложных типов при записи:

ТипЗначение
text / multiline / html / choiceстрока (choice — точно из settings.choices)
numberdouble / int
date"yyyy-MM-dd"
datetimeISO UTC (O / Z)
booleantrue / false
lookupобъект { itemId, display } или массив при allowMultiple: true
personобъект или массив { principalType, id, display }
link{ url, description }

Платформенный пиплпикер с тем же UI, что у стандартных форм списков: чипы, поиск, пользователи и группы, единичный / множественный выбор.

После каждого render / action оболочка WebPart вызывает PortalPersonPicker.bindAll(container) — достаточно разметки data-portal-person-picker. Скрытое поле name попадает в автосбор формы; на сервере: form.GetJson("assigneeJson").

Атрибут / опцияНазначение
data-name / nameимя hidden для автосбора (assignee_json)
data-list-id + data-field-idпоиск через person-options (учитывает настройки поля)
data-allow-multipletrue (массив) / false (один объект); по умолчанию true
data-allow-users / data-allow-groupsфильтр типов (если нет fieldId — поиск по /users и /groups)
data-readonlyтолько просмотр
<script type="application/json" data-portal-person-value>начальное значение

Формат значения (как у поля person):

[{ "principalType": "user", "id": "uuid", "display": "Иван Иванов" },
{ "principalType": "group", "id": "uuid", "display": "Редакторы HR" }]

При allowMultiple: false — один объект или пустая строка в hidden (null при сохранении).

В dist/main.js пакета (опционально, если нужны колбэки):

function bind(root) {
// платформа уже вызвала bindAll; можно переинициализировать с опциями:
const host = root.querySelector('[data-portal-person-picker]');
const api = PortalPersonPicker.mount(host, {
name: 'assignee_json',
listId: host.dataset.listId,
fieldId: host.dataset.fieldId,
allowMultiple: true,
allowUsers: true,
allowGroups: true,
onChange(value, items) {
// value — object | array | null; items — всегда массив
},
});
// или jQuery:
// $(host).portalPersonPicker({ name: 'assignee_json', listId, fieldId });
// const value = $(host).portalPersonPicker('getValue');
// $(host).portalPersonPicker('setValue', [{ principalType: 'user', id, display }]);
// const json = $(host).portalPersonPicker('getJson');
}

API экземпляра: getValue / getItems / getJson / setValue / setJson / clear / destroy.

Без listId+fieldId контрол ищет по /api/v1/users и /api/v1/groups с учётом allowUsers / allowGroups. С полем списка предпочтительнее person-options — сервер сам применит настройки поля.

Низкоуровневый REST (если пишете свой UI):
GET /api/v1/lists/{listId}/person-options?fieldId={uuid}&q=…

Тот же chip+search UI, что у person. После render платформа вызывает PortalLookupPicker.bindAll(container).

Атрибут / опцияНазначение
data-nameимя hidden (room_jsonform.GetJson("roomJson"))
data-list-idтекущий список (для lookup-options, проверка прав)
data-target-list-idсписок-источник (settings.lookupListId)
data-lookup-field-idполе отображения (опц., settings.lookupFieldId)
data-allow-multiplefalse по умолчанию (один объект); true — массив
<script type="application/json" data-portal-lookup-value>начальное значение

Формат значения:

{ "itemId": "12", "display": "Переговорная 301" }

При allowMultiple: true:

[{ "itemId": "12", "display": "301" }, { "itemId": "15", "display": "402" }]
PortalLookupPicker.mount(el, {
name: 'room_json',
listId,
targetListId,
lookupFieldId,
allowMultiple: false,
onChange(value, items) { /* ... */ },
});
// $(el).portalLookupPicker('getValue' | 'setValue' | 'getJson')

REST: GET /api/v1/lists/{listId}/lookup-options?targetListId=…&q=…&lookupFieldId=…

В настройках поля списка: Множественный выбор (settings.allowMultiple). По умолчанию выключен.


Форма списка (URL /items/12/edit или /items/new)
│ платформа → invoke.host
ctx.Host (ListId, ItemRef, FormMode)
GetOrNewListItemAsync → PortalListItem
HTML form (name = internal_name) + data-wp-action
│ автосбор полей платформой
HandleActionAsync → item["…"] = … → CreateAsync / UpdateAsync

СимптомЧто сделать
Choice 400Строка должна совпадать с settings.choices
Дата невернаТолько YYYY-MM-DD
Datetime сдвинутПишите UTC; в форму — GetDateTimeLocal
Person / lookup 400Проверьте структуру объекта (principalType/id/display)
Пиплпикер пустой / не ищетНужны data-list-id + data-field-id (или /users+/groups); актуальный фронт с PortalPersonPicker
«Поле обязательно»Не затирайте обязательные поля null без нужды
Поля формы не доходят в actionОберните в <form> или [data-wp-collect]; проверьте name
На форме списка нет «Сохранить»showStandardForm: false — сохраняет только WebPart
Host.ListId пустойWebPart не на форме списка; либо задайте список в Property Pane сами
Всегда пустая форма на editПроверьте, что пакет/фронт передаёт host (нужна актуальная платформа)

Если нужен полный контроль:

  1. GetFieldsAsync → словарь internal_name → id.
  2. Читать field_values[uuid] через PortalItemJson.
  3. Писать Dictionary в CreateItemAsync / UpdateItemAsync (ключи UUID или internal_name).

Подробности: Значения полей списков.


ПакетЧто смотреть
korport-base-taskstext, choice, date, person
korport-base-meeting-roomslookup, datetime, person
korport-itsm-ticket-portalзаявка: lookup, person, choice

Для новой WebPart используйте dotnet new portal-webpart.