Кастомная форма: все типы полей
Как сделать WebPart с формой создания / редактирования элемента списка: загрузить значения всех типов полей и сохранить их обратно.
Рекомендуемый путь:
ctx.Host— текущий список / элемент / режим формы (платформа передаёт с URL формы).PortalListItem—item["title"],CreateAsync/UpdateAsync.
Низкоуровневый UUID-путь — в конце страницы.
Справочник форматов значений: Значения полей списков.
Контекст хоста и API: SDK WebPart.
Параметры WebPart (если список задаёте вручную): Параметры.
Контролы и data-wp-action: Интерфейс и API.
Классы полей и кнопок: Стили PortalUI.
Что получится
Заголовок раздела «Что получится»- WebPart на форме списка (создание / просмотр / правка) —
listIdи номер элемента берутся изctx.Host, не из Property Pane. - Форма со всеми типами полей Portal.
- Создание и обновление через
PortalListItem— без ручного словаря UUID.
Типы полей: text, multiline_text, html_text, choice, number, date, datetime, boolean, lookup, person, link.
Вложения — отдельный API /items/{id}/attachments.
0. Подготовка
Заголовок раздела «0. Подготовка»- Создайте пакет из Extension SDK:
dotnet new portal-webpart -n DemoForm -o ./demo-form- В
manifest.jsonдля формы списка не нуженitemId. Список тоже обычно не нужен в панели — он уже вctx.Host.ListId. Property Pane оставляйте пустым или только под опции UI:
{ "properties": {}}Если WebPart стоит на обычной странице и должна работать с выбранным списком — добавьте listPicker (Параметры). Номер элемента с панели для форм не задавайте: на форме /items/12/edit платформа сама кладёт 12 в ctx.Host.ItemRef.
- Установите
.portalpart. Разместите на форме списка (Настройки списка → Формы → Создание / Просмотр / Редактирование). При выключенной стандартной форме сохранение делает ваша WebPart.
1. Контекст формы: ctx.Host
Заголовок раздела «1. Контекст формы: ctx.Host»На форме списка платформа передаёт в invoke:
| Поле | Пример | Когда |
|---|---|---|
Kind | ListForm | форма списка |
ListId | GUID списка | всегда на форме |
ItemRef | "12" | view / edit |
FormMode | New / View / Edit | режим формы |
NodeId | GUID узла | узел списка |
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.
2. Чтение и запись: PortalListItem
Заголовок раздела «2. Чтение и запись: PortalListItem»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, .Displayvar people = item.GetPerson("assignee"); // список PortalPersonValuevar 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") == "Высокий");Типы за var (чтение)
Заголовок раздела «Типы за var (чтение)»Все value-типы — records в Portal.Contracts.Lists.
| Выражение | Тип | Примечание |
|---|---|---|
ctx.Host.GetOrNewListItemAsync | PortalListItem | форма списка: load или new |
item / created | PortalListItem | результат GetListItemAsync / NewListItemAsync |
items | IReadOnlyList<PortalListItem> | QueryListItemsAsync / GetListItemsAsync |
item["title"] | object? | индексатор; для строки: (string?)item["title"] |
GetString | string? | text, multiline, choice и др. как строка |
GetDate | string? | поле date → YYYY-MM-DD (не DateTime) |
GetDateTimeLocal | DateTime? | поле datetime, локальное время процесса |
GetBoolean | bool | нет значения → false; nullable: TryGetBoolean → bool? |
GetNumber | double? | числовые поля |
GetLookup | PortalLookupValue? | .ItemId, .Display, .ItemGuid |
GetPerson | IReadOnlyList<PortalPersonValue> | у каждого .PrincipalType, .Id, .Display |
GetLink | PortalLinkValue? | .Url, .Description |
Ключи — internal_name полей (title, status, …). Сервер принимает их при записи; в ответах API по-прежнему UUID, но PortalListItem скрывает это.
Тот же API есть в Event Receiver и Timer Job SDK (api.Lists.GetListItemAsync / NewListItemAsync).
3. Разметка формы
Заголовок раздела «3. Разметка формы»Поля с атрибутом 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 нормализуются.
4. Заполнение формы (RenderAsync)
Заголовок раздела «4. Заполнение формы (RenderAsync)»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 / html | GetString |
| number | GetNumber |
| date | GetDate → YYYY-MM-DD |
| datetime | GetDateTimeLocal → для datetime-local |
| boolean | GetBoolean |
| lookup | GetLookup (один/первый) / GetLookups (все; multi при settings.allowMultiple: true) |
| person | GetPerson (список; при allowMultiple: false обычно один элемент) |
| link | GetLink |
5. Сохранение из HandleActionAsync
Заголовок раздела «5. Сохранение из HandleActionAsync»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) |
| number | double / int |
| date | "yyyy-MM-dd" |
| datetime | ISO UTC (O / Z) |
| boolean | true / false |
| lookup | объект { itemId, display } или массив при allowMultiple: true |
| person | объект или массив { principalType, id, display } |
| link | { url, description } |
6. Пикеры person и lookup
Заголовок раздела «6. Пикеры person и lookup»Person — PortalPersonPicker (готовый контрол)
Заголовок раздела «Person — PortalPersonPicker (готовый контрол)»Платформенный пиплпикер с тем же 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-multiple | true (массив) / 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 при сохранении).
Инициализация из JS / jQuery
Заголовок раздела «Инициализация из JS / jQuery»В 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=…
Lookup — PortalLookupPicker (готовый контрол)
Заголовок раздела «Lookup — PortalLookupPicker (готовый контрол)»Тот же chip+search UI, что у person. После render платформа вызывает PortalLookupPicker.bindAll(container).
| Атрибут / опция | Назначение |
|---|---|
data-name | имя hidden (room_json → form.GetJson("roomJson")) |
data-list-id | текущий список (для lookup-options, проверка прав) |
data-target-list-id | список-источник (settings.lookupListId) |
data-lookup-field-id | поле отображения (опц., settings.lookupFieldId) |
data-allow-multiple | false по умолчанию (один объект); 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). По умолчанию выключен.
7. Полный цикл
Заголовок раздела «7. Полный цикл»Форма списка (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 / UpdateAsync8. Частые ошибки
Заголовок раздела «8. Частые ошибки»| Симптом | Что сделать |
|---|---|
| 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 (нужна актуальная платформа) |
9. Низкоуровневый API (UUID)
Заголовок раздела «9. Низкоуровневый API (UUID)»Если нужен полный контроль:
GetFieldsAsync→ словарьinternal_name → id.- Читать
field_values[uuid]черезPortalItemJson. - Писать
DictionaryвCreateItemAsync/UpdateItemAsync(ключи UUID илиinternal_name).
Подробности: Значения полей списков.
Эталонные примеры
Заголовок раздела «Эталонные примеры»| Пакет | Что смотреть |
|---|---|
korport-base-tasks | text, choice, date, person |
korport-base-meeting-rooms | lookup, datetime, person |
korport-itsm-ticket-portal | заявка: lookup, person, choice |
Для новой WebPart используйте dotnet new portal-webpart.