Кастомная форма: все типы полей
Как сделать WebPart с формой создания / редактирования элемента списка: загрузить значения всех типов полей и сохранить их обратно.
Рекомендуемый путь — SharePoint-подобный API PortalListItem (item["title"], CreateAsync / UpdateAsync). Низкоуровневый UUID-путь — в конце страницы.
Справочник форматов значений: Значения полей списков.
Параметры WebPart (какой список): Параметры.
Контролы и data-wp-action: Интерфейс и API.
Классы полей и кнопок: Стили PortalUI.
Что получится
Заголовок раздела «Что получится»- WebPart с Property Pane (
listId). - Форма со всеми типами полей 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:
{ "properties": { "listId": { "type": "listPicker", "label": "Список", "required": true }, "itemId": { "type": "text", "label": "Номер элемента (для правки)" } }}Список в другом узле: Параметры §3.2.
- Установите
.portalpart. Разместите на странице или на форме списка (Настройки списка → Формы). При выключенной стандартной форме сохранение делает ваша WebPart.
1. Чтение и запись: PortalListItem
Заголовок раздела «1. Чтение и запись: PortalListItem»using Portal.Contracts.Lists;using Portal.WebPart.Sdk;
var listId = ctx.Properties.GetGuid("listId");
// --- Редактирование ---var item = await ctx.Lists.GetListItemAsync(listId, "12", 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";await item.UpdateAsync(ct); // merge: только изменённые поля
// --- Создание ---var created = await ctx.Lists.NewListItemAsync(listId, ct);created["title"] = "Новая задача";created["status"] = "Новая";created["due"] = "2026-07-15";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.
| Выражение | Тип | Примечание |
|---|---|---|
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).
2. Разметка формы
Заголовок раздела «2. Разметка формы»Поля с атрибутом 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 / person: hidden + ваш пикер (§5) --> <input type="hidden" name="room_item_id" value="{{roomItemId}}"> <input type="hidden" name="room_display" value="{{roomDisplay}}"> <input type="hidden" name="assignee_json" value="{{assigneeJson}}">
<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 как bodyHtml / startAt (после нормализации GetDataString).
3. Заполнение формы при редактировании
Заголовок раздела «3. Заполнение формы при редактировании»var listId = ctx.Properties.GetGuid("listId");var itemRef = ctx.Properties.GetString("itemId");if (string.IsNullOrEmpty(itemRef)){ return new WebPartResult(RenderForm(/* пустые значения */, itemId: ""));}
var item = await ctx.Lists.GetListItemAsync(listId, itemRef, ct);
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 room = item.GetLookup("room");var roomItemId = room?.ItemId ?? "";var roomDisplay = room?.Display ?? "";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 |
| person | GetPerson (список; при allowMultiple: false обычно один элемент) |
| link | GetLink |
4. Сохранение из HandleActionAsync
Заголовок раздела «4. Сохранение из HandleActionAsync»public async Task<WebPartResult> HandleActionAsync( string action, JsonElement data, IWebPartContext ctx, CancellationToken ct){ if (action != "save") return new WebPartResult("", Error: "Неизвестное действие");
var listId = ctx.Properties.GetGuid("listId"); var itemId = GetDataString(data, "itemId");
try { PortalListItem item = string.IsNullOrEmpty(itemId) ? await ctx.Lists.NewListItemAsync(listId, ct) : await ctx.Lists.GetListItemAsync(listId, itemId, ct);
ApplyFormData(item, data);
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, JsonElement data){ string S(string key) => GetDataString(data, key).Trim();
item["title"] = S("title"); item["description"] = EmptyToNull(S("description")); item["body_html"] = EmptyToNull(S("bodyHtml")); item["status"] = S("status");
var amountRaw = S("amount"); item["amount"] = double.TryParse(amountRaw, NumberStyles.Any, CultureInfo.InvariantCulture, out var amount) ? amount : null;
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"] = string.Equals(S("isActive"), "true", StringComparison.OrdinalIgnoreCase);
var roomId = S("roomItemId"); item["room"] = string.IsNullOrEmpty(roomId) ? null : new Dictionary<string, object?> { ["itemId"] = roomId, ["display"] = S("roomDisplay") };
var assigneeJson = S("assigneeJson"); item["assignee"] = string.IsNullOrEmpty(assigneeJson) ? null : JsonSerializer.Deserialize<JsonElement>(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;
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 "";}Подставьте свои 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 } |
| person | объект или массив { principalType, id, display } |
| link | { url, description } |
5. Пикеры person и lookup
Заголовок раздела «5. Пикеры person и lookup»Person: GET /api/v1/lists/{listId}/person-options?fieldId={uuid}&q=…
Lookup: GET /api/v1/lists/{listId}/lookup-options?targetListId=…&q=…
fieldId / lookupListId — из схемы поля (GetFieldsAsync или item.Schema). Результат положите в hidden и в ApplyFormData как выше.
6. Полный цикл
Заголовок раздела «6. Полный цикл»Property Pane: listId │ ▼GetListItemAsync / NewListItemAsync │ ▼HTML form (name = internal_name) + data-wp-action │ автосбор полей платформой ▼HandleActionAsync → item["…"] = … → CreateAsync / UpdateAsync7. Частые ошибки
Заголовок раздела «7. Частые ошибки»| Симптом | Что сделать |
|---|---|
| Choice 400 | Строка должна совпадать с settings.choices |
| Дата неверна | Только YYYY-MM-DD |
| Datetime сдвинут | Пишите UTC; в форму — GetDateTimeLocal |
| Person / lookup 400 | Проверьте структуру объекта |
| «Поле обязательно» | Не затирайте обязательные поля null без нужды |
| Поля формы не доходят в action | Оберните в <form> или [data-wp-collect]; проверьте name |
| На форме списка нет «Сохранить» | showStandardForm: false — сохраняет только WebPart |
8. Низкоуровневый API (UUID)
Заголовок раздела «8. Низкоуровневый 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.