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:
policy ManagesFields => IsAdmin;Signature#
[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#
One field type and one value type per kind of record#
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#
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#
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#
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#
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). 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#
On an app that also uses a report builder (The Reports kit — reports a team builds, and the overview it opens every morning), 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#
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).
Examples#
A guest list's own fields#
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#
- entity Sub : Base — a field type and a value type derived from the kit's bases
- invariant — the rules a value holds on every write
- Declared personal data — say whose a row is, and the platform answers for it — 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 — the settings area the sections sit in