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

Разметка WebPart на Razor

Пошаговое руководство для разработчика, который пишет вебчасть Portal и раньше не работал с Razor. Если вы собирали HTML строками в C# или через {{ключ}} в Templates/*.html, этот файл — новый основной способ. Старый способ остаётся и описан в конце.

Связанные страницы: стили кнопок и полей, что делает data-wp-action, свойства Property Pane, сборка .portalpart.

Razor — это HTML-файл с расширением .cshtml, в котором в нужных местах стоит C#.

  • Обычные теги пишутся как в HTML. Редактор подсвечивает разметку, кавычки в атрибутах экранировать не нужно.
  • Выражение @Model.Title подставляет значение и само кодирует его: < станет &lt;. Отдельный HtmlEncode для таких мест не нужен.
  • Условия и циклы — обычные @if и @foreach.
  • Файл компилируется вместе с C# при dotnet publish и попадает в ту же DLL. Отдельный шаблонизатор на сервере не запускается.
  • Браузер по-прежнему получает готовую строку HTML. Razor выполняется на сервере, внутри RenderAsync.

Вебчасть по-прежнему реализует IWebPart. Клик по кнопке с data-wp-action по-прежнему вызывает HandleActionAsync. Меняется только то, где лежит разметка.

Уже установленные модули (задачи, отпуска, ITSM и остальные пакеты на StringBuilder или WebPartTemplate) этот способ не заменяет. Их исходники и собранные .portalpart работают как раньше. Razor подключается только в проекте, где есть .cshtml.

Нужен Extension SDK той же версии, что установленный Portal: korport.ru/developers.

Окно терминала
dotnet new install ./portal-sdk-1.0.0/templates/portal-webpart
dotnet new portal-webpart -n MyWidget -o ./my-widget
cd ./my-widget

Команда -n MyWidget заменяет в шаблоне заготовку MyWebPart на MyWidget. Получаются:

ФайлЗачем
MyWidgetHandler.csКласс IWebPart: данные и действия
Views/Widget.cshtmlРазметка страницы
Views/_ViewImports.cshtmlОбщие директивы Razor для всех представлений проекта
MyWidget.csprojПроект Microsoft.NET.Sdk.Razor. Обычно не трогают
manifest.jsonКлюч в каталоге, имя класса, свойства Property Pane

Поле id в manifest.json шаблон не переименовывает: там остаётся contoso.my-webpart. Его стоит сменить на свой ключ, например contoso.my-widget, и тот же ключ потом использовать в JS, если он появится.

Сборка и установка:

Окно терминала
./portal-sdk-1.0.0/scripts/pack-portalpart.sh ./my-widget
Окно терминала
.\portal-sdk-1.0.0\scripts\pack-portalpart.ps1 .\my-widget

Файл dist/my-widget.portalpart загружается в Админка → WebPart. После правки кода увеличьте version в manifest.json, соберите пакет снова и загрузите его с тем же id. Перезапуск API не нужен.

Проверьте заготовку до своих правок: поставьте вебчасть на страницу. Должен появиться заголовок из Property Pane и два пункта списка.

C# готовит объект. Razor этот объект рисует.

MyWidgetHandler.cs:

public async Task<WebPartResult> RenderAsync(IWebPartContext ctx, CancellationToken ct)
{
var model = new MyWidgetModel
{
Title = ctx.Properties.GetString("title", "Мой виджет"),
Items = ["Первый", "Второй"],
};
var html = await WebPartRazor.RenderAsync("Views/Widget.cshtml", model);
return new WebPartResult(html);
}

WebPartRazor.RenderAsync без первого аргумента-сборки ищет .cshtml в той DLL, из которой его вызвали. Путь Views/Widget.cshtml и короткое имя Widget означают одно и то же представление.

Views/Widget.cshtml из шаблона:

@model Contoso.MyWidget.MyWidgetModel
<div class="my-webpart">
<h3 class="my-webpart__title">@Model.Title</h3>
@if (Model.Items.Count == 0)
{
<p class="webpart-empty">Нет элементов</p>
}
else
{
<ul>
@foreach (var item in Model.Items)
{
<li>@item</li>
}
</ul>
}
</div>

Строка @model объявляет тип объекта, который вы передали в RenderAsync. После неё в файле доступен Model.

Класс модели лежит рядом с обработчиком. Свойства должны быть доступны Razor: public, с get.

public sealed class MyWidgetModel
{
public required string Title { get; init; }
public required IReadOnlyList<string> Items { get; init; }
}

Имя класса после dotnet new -n MyWidget будет MyWidgetModel, пространство имён — Contoso.MyWidget. В @model пишите то же полное имя, что у класса в C#.

3. Синтаксис Razor, которого хватает для вебчасти

Заголовок раздела «3. Синтаксис Razor, которого хватает для вебчасти»

Дальше — только те конструкции, которые нужны в разметке Portal. Остальной Razor (layouts, tag helpers MVC, @inject) вебчасти не используют.

<h3>@Model.Title</h3>
<input class="tbx__control" value="@Model.Search">

@Model.Title кодируется и в тексте, и в атрибуте. Если в заголовке окажется <script>, на страницу попадёт текст, а не тег.

Несколько свойств подряд пишут в скобках, иначе Razor не поймёт, где выражение кончается:

<p>Найдено: @(Model.Items.Count)</p>
<button class="btn btn--settings btn--sm @(Model.Scope == "mine" ? "is-active" : null)">Мои</button>

Символ @ в тексте письма или адресе удваивают: support@@contoso.ru.

@if (!string.IsNullOrEmpty(Model.FormError))
{
<p class="form-error">@Model.FormError</p>
}
else if (Model.Items.Count == 0)
{
<p class="webpart-empty">Заявок нет</p>
}

Фигурные скобки обязательны. Внутри них снова HTML.

<select class="tbx__control" name="status">
<option value="">Все статусы</option>
@foreach (var item in Model.Statuses)
{
<option value="@item.Value" selected="@(item.Value == Model.Status)">@item.Label</option>
}
</select>

selected="@(условие)" — приём Razor для логического атрибута. Если условие истинно, в HTML будет selected="selected". Если ложно, атрибут не попадёт в разметку. То же работает для checked и disabled.

@* это комментарий Razor, в HTML его не будет *@

<!-- комментарий --> попадёт в HTML, который увидит браузер.

Почти всё кодируется. Исключение — фрагмент, который уже является HTML или JSON и должен остаться как есть. Типичный случай — значение пикера сотрудника внутри <script type="application/json">: кавычки JSON нельзя превращать в &quot;.

<script type="application/json" data-portal-person-value>@Html.Raw(Model.AssigneeJson)</script>

AssigneeJson должен быть JSON, который собрал ваш C# (JsonSerializer), а не текст, введённый пользователем в поле. Пользовательский текст в @Html.Raw не передают.

Форму, карточку или строку таблицы выносят в соседний .cshtml. В Views/_ViewImports.cshtml шаблон уже подключает один тег:

@addTagHelper Microsoft.AspNetCore.Mvc.TagHelpers.PartialTagHelper, Microsoft.AspNetCore.Mvc.TagHelpers

Этой строки достаточно. Не заменяйте её на @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers. Полный набор тегов MVC перехватывает обычные <form>, <input> и <a> и начинает вести себя как форма ASP.NET, а не как контрол портала.

Вставка:

@if (Model.ShowCreate)
{
<partial name="CreateForm" model="Model" />
}

name — имя файла без .cshtml. Файл Views/CreateForm.cshtml начинается со своего @model и получает тот же объект.

Классы кнопок и полей — из PortalUI, чтобы виджет выглядел как остальной портал. Полный набор: Стили и контролы. Здесь — те, с которых начинают.

Корневой блок называйте своим префиксом, например webpart-requests, и дальше используйте его в классах. Так стили не заденут чужие вебчасти.

ЭлементКлассЗаметка
Главная кнопкаbtn btn--primary«Создать», «Сохранить»
Отменаbtn btn--cancelРядом с главной кнопкой, в диалоге
Кнопка тулбараbtn btn--settings btn--sm«Обновить», переключатели
Поле, список, многострочный текстtbx__controlНа <input>, <select>, <textarea>
Ошибкаform-errorТекст ошибки из модели
Пустоwebpart-emptyКогда список пуст
Подсказкаwebpart-hintНапример «укажите список в настройках»

Иконка Font Awesome и подпись кнопки:

<button type="button" class="btn btn--primary btn--sm" data-wp-action="openCreate">
<i class="fa-solid fa-plus" aria-hidden="true"></i>
<span class="btn__text">Создать</span>
</button>

Поле:

<label>Тема
<input type="text" class="tbx__control" name="title" required value="@Model.Draft.Title">
</label>

Атрибут name нужен, если значение должно уехать на сервер вместе с нажатием кнопки. Как это устроено — в следующем разделе.

Оболочка страницы сама слушает элементы с data-wp-action. Свой fetch и свой click в main.js для такой кнопки не пишут.

Цепочка:

  1. В .cshtml у кнопки стоит data-wp-action="имя".
  2. Пользователь нажимает кнопку (для input type="date" и select с тем же атрибутом срабатывает смена значения).
  3. Браузер шлёт POST /api/v1/webparts/invoke/{id}/action.
  4. Portal вызывает HandleActionAsync.
  5. Вы возвращаете новый HTML. Контейнер вебчасти заменяется целиком.

Имя в data-wp-action становится аргументом action. Дополнительные data-wp-* попадают в data.

<button type="button" class="btn btn--settings btn--sm"
data-wp-action="open"
data-wp-id="@item.Id">@item.Title</button>

В C#:

public async Task<WebPartResult> HandleActionAsync(
string action, JsonElement data, IWebPartContext ctx, CancellationToken ct)
{
var form = data.AsActionData();
if (action == "open")
{
var id = form.GetString("id");
return await RenderOpenedAsync(ctx, id, ct);
}
return new WebPartResult("", Error: "Неизвестное действие");
}

data-wp-id читается как form.GetString("id"). Регистр и snake_case SDK приводит к одному имени: data-wp-room-id, roomId и room_id — одно поле. Методы: GetString, GetBoolean, GetGuid, GetInt32, GetJson.

После действия снова вызывайте тот же рендер, что и в RenderAsync. Отдельного хранилища состояния в браузере нет: открытая форма, текст ошибки и фильтр должны оказаться в модели, из которой Razor рисует страницу заново.

Ниже связка, которую можно перенести в свой проект. Список статусов и строк рисует Razor. Кнопки только называют действие.

public sealed class RequestsModel
{
public string Search { get; init; } = "";
public string Status { get; init; } = "";
public bool ShowCreate { get; init; }
public string FormError { get; init; } = "";
public string DraftTitle { get; init; } = "";
public IReadOnlyList<StatusChoice> Statuses { get; init; } = [];
public IReadOnlyList<RequestRow> Items { get; init; } = [];
}
public sealed class StatusChoice
{
public required string Value { get; init; }
public required string Label { get; init; }
}
public sealed class RequestRow
{
public required string Id { get; init; }
public required string Number { get; init; }
public required string Title { get; init; }
public required string Status { get; init; }
}
@model Contoso.MyWidget.RequestsModel
<div class="webpart-requests">
<div class="webpart-requests__toolbar">
<input type="search" class="tbx__control" name="search"
placeholder="Поиск" value="@Model.Search">
<select class="tbx__control" name="status">
<option value="">Все статусы</option>
@foreach (var item in Model.Statuses)
{
<option value="@item.Value" selected="@(item.Value == Model.Status)">@item.Label</option>
}
</select>
<button type="button" class="btn btn--settings btn--sm" data-wp-action="filter">Найти</button>
<button type="button" class="btn btn--primary btn--sm" data-wp-action="openCreate">
<span class="btn__text">Создать</span>
</button>
</div>
@if (!string.IsNullOrEmpty(Model.FormError))
{
<p class="form-error">@Model.FormError</p>
}
@if (Model.Items.Count == 0)
{
<p class="webpart-empty">Заявок нет</p>
}
else
{
<table class="tbl">
<thead>
<tr><th>Номер</th><th>Тема</th><th>Статус</th></tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
{
<tr>
<td>@item.Number</td>
<td>
<button type="button" class="btn btn--settings btn--sm"
data-wp-action="open" data-wp-id="@item.Id">@item.Title</button>
</td>
<td>@item.Status</td>
</tr>
}
</tbody>
</table>
}
@if (Model.ShowCreate)
{
<partial name="CreateForm" model="Model" />
}
</div>

Кнопка «Найти» лежит рядом с полями, но не внутри <form>. Тогда оболочка ищет поля у родителя кнопки и забирает name="search" и name="status". На сервере это form.GetString("search") и form.GetString("status").

Надёжнее обернуть тулбар в <form onsubmit="return false;"> или в <div data-wp-collect>. Оболочка берёт поля из ближайшего <form>, иначе из [data-wp-collect], иначе от родителя кнопки. Явный data-wp-* на самой кнопке важнее одноимённого поля. Участок, который собирать не нужно, помечают data-wp-no-collect.

@model Contoso.MyWidget.RequestsModel
<form class="webpart-requests__dialog" onsubmit="return false;">
<h3>Новая заявка</h3>
<label>Тема
<input type="text" class="tbx__control" name="title" required value="@Model.DraftTitle">
</label>
<div class="webpart-requests__dialog-actions">
<button type="button" class="btn btn--primary" data-wp-action="create">Создать</button>
<button type="button" class="btn btn--cancel" data-wp-action="cancelCreate">Отмена</button>
</div>
</form>

type="button", не submit. Отправку делает data-wp-action, а не обычный submit формы. onsubmit="return false;" гасит случайную отправку по Enter.

Поля этой формы (name="title") уедут вместе с «Создать», потому что кнопка внутри <form>. Поля фильтра снаружи этой формы в это действие не попадают.

public async Task<WebPartResult> HandleActionAsync(
string action, JsonElement data, IWebPartContext ctx, CancellationToken ct)
{
var form = data.AsActionData();
return action switch
{
"filter" => await RenderAsync(ctx, form.GetString("search"), form.GetString("status"), showCreate: false, formError: "", draftTitle: "", ct),
"openCreate" => await RenderAsync(ctx, form.GetString("search"), form.GetString("status"), showCreate: true, formError: "", draftTitle: "", ct),
"cancelCreate" => await RenderAsync(ctx, form.GetString("search"), form.GetString("status"), showCreate: false, formError: "", draftTitle: "", ct),
"create" => await CreateAsync(ctx, form, ct),
"open" => await OpenAsync(ctx, form.GetString("id"), ct),
_ => new WebPartResult("", Error: "Неизвестное действие"),
};
}
private async Task<WebPartResult> CreateAsync(IWebPartContext ctx, WebPartActionData form, CancellationToken ct)
{
var title = form.GetString("title").Trim();
if (title.Length == 0)
{
return await RenderAsync(ctx, search: "", status: "", showCreate: true, formError: "Укажите тему", draftTitle: "", ct);
}
await ctx.Lists.CreateItemAsync(ctx.Properties.GetGuid("listId"), new Dictionary<string, object?>
{
["title"] = title,
}, ct);
return await RenderAsync(ctx, search: "", status: "", showCreate: false, formError: "", draftTitle: "", ct);
}

RenderAsync в этом примере собирает RequestsModel и вызывает WebPartRazor.RenderAsync. Ошибка «Укажите тему» — обычная строка модели. В .cshtml она попадает в <p class="form-error"> и кодируется сама.

Флажок читается так: form.GetBoolean("notify"), если в разметке <input type="checkbox" name="notify">. Снятый флажок приходит как ложь.

Оформление, которого нет среди классов портала, кладут в assets/main.css. В manifest.json после сборки pack-portalpart сам пропишет styles. В CSS используйте префикс корневого блока (.webpart-requests), цвета — через var(--color-surface) и соседние переменные темы.

JavaScript нужен, когда жест не должен ходить на сервер: локальный поиск по уже нарисованным строкам, раскрытие блока. Файл assets/main.js регистрирует bind. Оболочка вызывает его после каждого серверного рендера.

(function () {
const KEY = 'contoso.my-widget';
function bind(root) {
if (!root || root.dataset.uiBound === '1') return;
root.dataset.uiBound = '1';
}
window.PortalWebPartClients = window.PortalWebPartClients || {};
window.PortalWebPartClients[KEY] = { bind };
})();

KEY совпадает с id в manifest.json. Кнопки с data-wp-action в bind не вешают: их обрабатывает оболочка.

В .cshtml не кладут <script> с логикой страницы. Такой скрипт выполнится как HTML, который вставили в контейнер, и его легко продублировать при следующем рендере.

Что видитеПочему
Представление не найдено, в тексте перечислены другие .cshtmlПуть в RenderAsync не совпал с файлом. Проверьте Views/ и имя
Имя представления неоднозначноДва файла с одним коротким именем. Укажите путь Views/Form.cshtml
В заголовке видны символы &lt;В модель попал уже закодированный текст, и Razor закодировал его ещё раз. В модель кладите обычную строку
Кавычки JSON пикера превратились в &quot;Фрагмент вставлен через @Model.Json. Для готового JSON нужен @Html.Raw
<form> внезапно получил скрытые поля ASP.NETВ _ViewImports.cshtml подключён весь набор tag helper. Оставьте только PartialTagHelper
Кнопка ничего не вызываетНет data-wp-action, либо type="submit" увёл браузер со страницы. Ставьте type="button"
Поле формы не пришло в HandleActionAsyncУ поля нет name, оно disabled, либо кнопка собирает другую форму. Оберните поля и кнопку в один <form>
После правки на странице старая разметкаНе увеличена version или пакет не загружен заново

Ошибка в .cshtml (не закрытый @if, неизвестное свойство модели) видна на dotnet publish: пакет не соберётся. Это происходит до установки на портал.

Вебчасти, которые уже собраны строками C# или через WebPartTemplate и Templates/*.html, менять не требуется.

  • IWebPart, WebPartResult и data-wp-action те же.
  • WebPartTemplate.LoadAndApply по-прежнему подставляет {{ключ}} буквально. Значения по-прежнему кодируют до подстановки. Если прогнать такой шаблон через Razor, уже закодированный текст испортится, поэтому старые файлы оставляют на WebPartTemplate.
  • Проект без .cshtml собирается прежним Microsoft.NET.Sdk и прежним pack-portalpart.

Новую разметку с циклами и формами пишите в Views/*.cshtml. Короткий кусок вроде одной подсказки «укажите список» можно оставить строкой в C#:

return new WebPartResult("""<p class="webpart-hint">Укажите список в настройках WebPart.</p>""");

Подробности старого способа: HTML-шаблоны.