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

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

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

Рекомендуемый путь — SharePoint-подобный API PortalListItem (item["title"], CreateAsync / UpdateAsync). Низкоуровневый UUID-путь — в конце страницы.

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


  1. WebPart с Property Pane (listId).
  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:
{
"properties": {
"listId": { "type": "listPicker", "label": "Список", "required": true },
"itemId": { "type": "text", "label": "Номер элемента (для правки)" }
}
}

Список в другом узле: Параметры §3.2.

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

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, .Display
var people = item.GetPerson("assignee"); // список PortalPersonValue
var 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") == "Высокий");

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

ВыражениеТипПримечание
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 / 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).


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 / htmlGetString
numberGetNumber
dateGetDateYYYY-MM-DD
datetimeGetDateTimeLocal → для datetime-local
booleanGetBoolean
lookupGetLookup
personGetPerson (список; при allowMultiple: false обычно один элемент)
linkGetLink

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)
numberdouble / int
date"yyyy-MM-dd"
datetimeISO UTC (O / Z)
booleantrue / false
lookup{ itemId, display }
personобъект или массив { principalType, id, display }
link{ url, description }

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 как выше.


Property Pane: listId
GetListItemAsync / NewListItemAsync
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Проверьте структуру объекта
«Поле обязательно»Не затирайте обязательные поля null без нужды
Поля формы не доходят в actionОберните в <form> или [data-wp-collect]; проверьте name
На форме списка нет «Сохранить»showStandardForm: false — сохраняет только WebPart

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

  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.