# The CustomFields kit — fields admins add to records, forms that ask them, and an editor for each record

> `Osysharp.CustomFields` lets an app's admins add fields to its records without a release — text, numbers, yes or no, one of a list, a date; required or optional; asked of outside people or kept for the team — and group them on forms whose questions show on a condition. Each kind of record declares its own field and value types, so a value's security is its record's; the page and the server ask the same pure rules, and a value's own invariants hold its kind on every write.

<!-- id: ui-fields-kit · area: ui · stability: preview · html: https://osysharp.com/reference/ui/fields-kit/ -->

## Summary        {#summary}
`use Osysharp.CustomFields@1;` gives an app `CustomField` and `FieldValue` to derive one field type and one value type from
per kind of record, `FieldForm`/`FormField` for forms and their conditions, the rules a page and the server share, the
question controls, an in-place editor for a record's fields (`RecordFields`), and the settings sections that define
them. The app says one thing more — who defines fields:

```osy syntax
policy ManagesFields => IsAdmin;
```

## Signature      {#signature}
```osy syntax
[Authority(Extensible)] public entity CustomField { string Name; FieldKind Kind; string Options; string Hint;
  bool Public; bool Required; int Position; bool Active = true; }          // Text | Number | YesNo | Choice | Date
[Authority(Extensible)] public entity FieldForm { string Name; string Description; string Key; bool IsDefault;
  int Position; bool Active = true; }
[Authority(Extensible)] public entity FormField { FieldForm Form; CustomField Field; int Position;
  CustomField? ShowWhen; string ShowValue; }
[Authority(Extensible)] public entity FieldValue { CustomField Field; string Value; }   // the app's subtype adds its record

public interface IFieldRecords {
  bool Holds(CustomField field);
  FieldValue? AnswerOf(Guid record, CustomField field);
  FieldValue NewAnswer(Guid record, CustomField field);
  string LastChangedBy(Guid record);
  void Changed(Guid record, CustomField field, string before, string after);
}

void SetField(Guid record, Guid field, string value, string? expected = null);
List<FieldAnswer> Checked(FieldForm? form, List<FieldAnswer> given, bool outside);
string CheckedChange(CustomField field, string before, string value, string? expected, string lastChangedBy = "Someone");
List<Guid> ShownFields(List<FieldSpec> onForm, List<FieldAnswer> answers, bool outside);
string AnswerProblem(FieldSpec f, string value);   string Normalised(FieldSpec f, string value);
string ShownAnswer(FieldKind kind, string value);  List<FieldSpec> FormSpecs();   FieldSpec SpecOf(CustomField f);
CustomField FillField(CustomField field, FieldDraft draft, int position);
string FieldNameProblem(string name, Guid self, CustomField[] siblings);

component RecordFields(Guid record, List<FieldSpec> fields, List<FieldAnswer> held, string title = "Fields", …);
component FieldListSection(CustomField[] fields, Func<FieldDraft, CustomField> add, List<FieldUse> used, string title = "Fields", …);
component FormSection(FieldForm form, List<FormField> onForm, CustomField[] fields, bool fresh, Action makeDefault, …) { … }
```

## Description    {#description}

### One field type and one value type per kind of record   {#types}
A field is a row, so adding one is a settings change. The kind of record it belongs to is its TYPE: `entity
GuestField : CustomField`, `entity CompanyField : CustomField`. A value is a row of the app's own value type, which
carries the reference to its record — `entity GuestFieldValue : FieldValue { [Required] Guest Guest; }` — so the rules
on who reads and writes it are written over that reference, exactly as they are for the record. `[Unique(Guest, Field)]`
keeps one answer per field per record; the column `Field` is the base's, which a composite key may name.

### Writing an answer                         {#writing}
`SetField(record, field, value, expected)` checks the change — a retired field takes no new answer, an answer its kind
cannot hold is refused in a sentence naming the field, and an `expected` that is no longer what the record holds is
refused naming who changed it (`LastChangedBy`) — then writes it through the app's `IFieldRecords` for that kind of
record, found without being listed (`App.Implementations<IFieldRecords>()`), and calls its `Changed` (a history line, an
automation). Every read and write is the caller's, under the app's rules on its value type. `RecordFields` and
`FieldRow` call it from the page.

### A form asks some fields, each on a condition   {#forms}
A form asks some fields, in its order; each may be shown on one condition — an EARLIER field on the same form having a
given answer. `ShownFields` answers which show; `Checked(form, answers, outside: true)` is the server's check of an
outside person's answers: an answer to anything the form does not ask them is refused, one a condition hides is
dropped, a required one left empty is refused naming it, and every problem is said at once.

### Every write holds the field's kind        {#invariants}
`FieldValue`'s invariants read the field through the value's reference — a number is a number, yes or no is "yes" or
"no", a date is written "2026-10-02", a choice is one of the options — so they hold for a form, an edit, an import and a
row written straight past every function.

### Defining fields                           {#settings}
`FieldListSection` lists one kind's fields — moved, edited in a dialog, retired and brought back, with how many records
answered each — and adds one through a dialog that hands a `FieldDraft` to the app's method, which makes its own type
and answers it: `CustomField AddGuestField(FieldDraft d) => FillField(new GuestField(), d, fields.Count);`. The dialog
waits for that answer, so a field the app refuses — its method throws — keeps the dialog open with the app's own words,
and the field it makes is marked *Added* on the list (see [A callback that answers](https://osysharp.com/reference/ui/callback-value/)). Two fields of one kind never share a name: the dialog says so as the
name is typed, and the app's `[Unique(Name)]` on its field type is the guarantee — a `[Unique]` on a derived type holds
among that type's own rows, so a guest field and a company field may share a name. `FormSection` edits one form; what
the app adds to its own form type goes in its body. Nothing is kept until the page's Save.

### A record's fields are columns in a report   {#reports}
On an app that also uses a report builder ([The Reports kit — reports a team builds, and the overview it opens every morning](https://osysharp.com/reference/ui/reports-kit/)), every custom field is a column of its record — "Ticket ›
Customer › Their plan" groups, filters and is measured like a field the app declares, as its kind reads: a list by its
options, yes or no as Yes and No, a number summed and compared, a date grouped by week or month. The kit says how
(`FieldsInReports`, found without being listed); the app declares the relation back from the record to its answers,
`[ForeignKey(Guest)] GuestFieldValue[] Fields;`, beside the one-answer-per-field rule it already keeps.

### An erasure takes the answers with it      {#personal-data}
The value is classified `Personal.Content`. The app's value type says the category and whose it is, with
`[DataSubject]` on the record reference: `[PersonalData(GuestDetails)]` blanks the answer on erasure, like any personal
field the row holds, inherited or not, and `[PersonalData(GuestDetails, OnErasure.DeleteRow)]` removes the row
([Declared personal data — say whose a row is, and the platform answers for it](https://osysharp.com/reference/security/declared-personal-data/)).

## Examples       {#examples}

### A guest list's own fields               {#example-guests}
```osy title="a field type and a value type for guests, written through the kit" test app=ui-fields-kit
app GuestList {
  use Osysharp.CustomFields;
  use Osysharp.Ui;
  model "model/**/*.osy";
}

using Osysharp.CustomFields;
using Osysharp.Ui;

/// Who signs in — the venue's staff.
[Principal] entity Host {
  [Unique, MaxLength(200)] string Email;
  security { allow read when IsAuthenticated; }
}

policy ManagesFields => IsAuthenticated;

entity Guest {
  [Required("Name the guest."), MaxLength(120)] string Name;
  /// What the venue keeps about the guest — and, in a report, each field as a column of the guest.
  [ForeignKey(Guest)] GuestFieldValue[] Fields;
  security { allow read, create, update when IsAuthenticated; }
}

[Unique(Name, "Another guest field already has that name.")]
entity GuestField : CustomField {
  security { allow read, create, update when IsAuthenticated; }
}

[Unique(Guest, Field, "That guest already has an answer to that field.")]
entity GuestFieldValue : FieldValue {
  [Required] Guest Guest;
  security { allow read, create, update when IsAuthenticated; }
}

public class GuestFields : IFieldRecords {
  public bool Holds(CustomField field) => field is GuestField;
  public FieldValue? AnswerOf(Guid record, CustomField field) =>
    GuestFieldValue.FirstOrDefault(v => v.Guest.Id == record && v.Field == field);
  public FieldValue NewAnswer(Guid record, CustomField field) =>
    new GuestFieldValue { Guest = Guest.Single(g => g.Id == record), Field = field };
  public string LastChangedBy(Guid record) => "Someone";
  public void Changed(Guid record, CustomField field, string before, string after) { }
}

[Route("/guests/{id}")] [Authorize]
component GuestPage(Guid id) {
  live var fields = GuestField.OrderBy(f => f.Position)
    .Select(f => new FieldSpec { Id = f.Id, Name = f.Name, Kind = f.Kind, Options = f.Options, Hint = f.Hint,
                                 Public = f.Public, Required = f.Required, Active = f.Active }).ToList();
  live var held = GuestFieldValue.Where(v => v.Guest.Id == id)
    .Select(v => new FieldAnswer { Field = v.Field.Id, Value = v.Value }).ToList();
  render { RecordFields(id, fields, held, title: "About this guest"); }
}

[Route("/settings/fields")] [Authorize(ManagesFields)]
component FieldsSettings() {
  live var fields = GuestField.OrderBy(f => f.Position).ToList();
  live var used = GuestFieldValue.Where(v => v.Value != "").GroupBy(v => v.Field.Id)
    .Select(g => new FieldUse { Field = g.Key, Count = g.Count() }).ToList();
  CustomField AddGuestField(FieldDraft d) => FillField(new GuestField(), d, fields.Count);
  render {
    SettingsPage("Fields") {
      FieldListSection(fields, AddGuestField, used, title: "Guest fields", record: "guest", records: "guests");
      SaveBar(saved: "Fields saved.");
    }
  }
}
```

## See also       {#see-also}
- [entity Sub : Base](https://osysharp.com/reference/entity/inheritance/) — a field type and a value type derived from the kit's bases
- [invariant](https://osysharp.com/reference/entity/invariants/) — the rules a value holds on every write
- [Declared personal data — say whose a row is, and the platform answers for it](https://osysharp.com/reference/security/declared-personal-data/) — a person's answers in their erasure and their copy
- [[Settings] and [Setting]: a settings section declared on its row, each field drawn by its type](https://osysharp.com/reference/ui/settings-declared/) — the settings area the sections sit in
