# PkRecordEditor: the state of a create-or-edit record page

> Plainkit 0.28.0. Generated by scripts/build-skills.mjs from PkRecordEditor.cs. Do not edit: change the source and run the generator.

A plain C# class (no component, no element) holding what every record page repeats: load a record by id or start a blank one, a not-found flag, DataAnnotations validation, save, delete, `Busy` and `Error`. The page declares what is specific to its record with delegates; it pairs with `PkRecordForm`, which draws the page (`references/record-form.md`), but neither needs the other. `PkRecordEditor<TRecord, TForm, TKey>`: `TRecord` is the stored thing (null while adding), `TForm` the editable copy the fields bind to, `TKey` the id type (`int`, `Guid`, `string`). Write the three type arguments once, on the field, and construct with target-typed `new(...)`.

```razor
@* LocationEdit.razor: the record page over PkRecordEditor; the list page is the one in record-form.md *@
@page "/locations/new"
@page "/locations/{Id:int}"
@inject NavigationManager Nav
@inject ILocationRepository Repo
@inject ILogger<LocationEdit> Log

@if (_editor.Form is { } form)
{
    <PkPageHeader Crumbs="_crumbs" Title="@Title" />
    <PkRecordForm OnValid="SaveAsync" OnCancel="Back" OnDelete="_delete" Busy="_editor.Busy" Error="@_editor.Error">
        <form @onsubmit:preventDefault>
            <PkStack>
                <PkCard Heading="Details">
                    <PkField Label="Name" Required><PkInput @bind-Value="form.Name" Name="name" Required /></PkField>
                </PkCard>
            </PkStack>
        </form>
    </PkRecordForm>
}

@code {
    [Parameter] public int? Id { get; set; }

    private PkRecordEditor<Location, LocationForm, int> _editor = default!;
    private EventCallback _delete;
    private readonly PkCrumb[] _crumbs = [new PkCrumb("Locations", "/locations"), new PkCrumb("Location")];
    private string Title => _editor.IsNew ? "New location" : _editor.Record!.Name;

    protected override void OnInitialized() =>
        _editor = new(Log, "location", Repo.GetAsync, l => l is null ? new LocationForm() : LocationForm.From(l),
                      (form, location) => Repo.SaveAsync(location?.Id, form), location => Repo.DeleteAsync(location.Id));

    protected override async Task OnParametersSetAsync()
    {
        if (Id is { } id) await _editor.LoadAsync(id); else _editor.StartNew();
        if (_editor.NotFound) Nav.NavigateTo("/locations", replace: true);
        _delete = _editor.CanDelete ? EventCallback.Factory.Create(this, DeleteAsync) : default;
    }

    private async Task SaveAsync() { if (await _editor.SaveAsync()) Back(); }
    private async Task DeleteAsync() { if (await _editor.DeleteAsync()) Back(); }
    private void Back() => Nav.NavigateTo("/locations");
}
```

## Constructor

| Argument | Description |
|---|---|
| `ILogger logger` | receives unexpected failures (the app's own logger; this is not `IPkLog`, the browser-side log) |
| `string noun` | what the record is called in the log ("location") |
| `Func<TKey, Task<TRecord?>> load` | loads a record by id; null when there is none |
| `Func<TRecord?, TForm> toForm` | makes the form, from the record or from null for a new one |
| `Func<TForm, TRecord?, Task> save` | stores the form; the record is null for a new one |
| `Func<TRecord, Task>? delete` | deletes a record; without it `CanDelete` stays false |

## Members

| Member | Description |
|---|---|
| `StartNew()` | a blank form for a new record |
| `LoadAsync(TKey id)` | loads a record into the form; none found sets `NotFound` and clears `Form` and `Record` (redirect to the list). A `load` that throws propagates: it is a page error, not a form error |
| `SaveAsync()` | validates `Form` with DataAnnotations (`IValidatableObject` too; the messages are joined into `Error`), then calls `save`; true when saved. A second call while `Busy` is refused |
| `DeleteAsync()` | calls `delete` for the loaded record; true when deleted |
| `Form`, `Record`, `IsNew`, `NotFound`, `Busy`, `Error`, `CanDelete` | the state the page reads; `CanDelete` is a delete delegate and a loaded record |
| `ClearError()` | clears `Error` |

## Which errors the user sees

An exception thrown by `save` or `delete` that implements the marker interface `IPkUserFacingException` (no members) shows its `Message` as it is: a domain rule written for the person ("Only one location can be primary."). Put the marker on the exception base type the app already has (`class AppException(string message) : Exception(message), IPkUserFacingException;`), so every rule that derives from it is shown. When the domain layer must not reference PlainKit.Blazor, set the optional `IsUserFacing` (`Func<Exception, bool>`, an init property: `new PkRecordEditor<...>(...) { IsUserFacing = ex => ex is AppException }`); it is consulted in addition to the marker, so either one shows the message, and a predicate that throws is logged and counts as false. Any other exception is logged (`LogError` with the verb and noun) and the user sees "Unexpected error while saving. See log for details." (or "deleting"): an exception message is not written for users and can leak internals.
