Programmatic Dev Model

Customizing UI

Much of the configurable parts of the UIs can be customized in code, for a preview of the potential customizations available here's are the defaults the UIs are configured with which you can change with ConfigurePlugin<UiFeature>:

ConfigurePlugin<UiFeature>(feature => {
    feature.Info.HideTags = new List<string> { TagNames.Auth };
    feature.Info.BrandIcon = Svg.ImageUri(Svg.GetDataUri(Svg.Logos.ServiceStack, "#000000"));
    feature.Info.Theme = new ThemeInfo {
        Form = "shadow overflow-hidden sm:rounded-md bg-white",
        ModelIcon = Svg.ImageSvg(Svg.Create(Svg.Body.Table)),
    };
    feature.Info.Locode = new() {
        Css = new ApiCss {
            Form = "max-w-screen-2xl",
            Fieldset = "grid grid-cols-12 gap-6",
            Field = "col-span-12 lg:col-span-6 xl:col-span-4",
        },
        Tags = new AppTags {
            Default = "Tables",
            Other = "other",
        },
        MaxFieldLength = 150,
        MaxNestedFields = 2,
        MaxNestedFieldLength = 30,
    };
    feature.Info.Explorer = new() {
        Css = new ApiCss {
            Form = "max-w-screen-md",
            Fieldset = "grid grid-cols-12 gap-6",
            Field = "col-span-12 sm:col-span-6",
        },
        Tags = new AppTags {
            Default = "APIs",
            Other = "other",
        },
    };
    feature.Info.DefaultFormats = new ApiFormat {
        AssumeUtc = true,
        Date = new Intl(IntlFormat.DateTime) { Date = DateStyle.Medium }.ToFormat(),
    };
    feature.Info.AdminLinks = new() {
        new LinkInfo { Id = "", Label = "Dashboard", Icon = Svg.ImageSvg(Svg.Create(Svg.Body.Home)) },
    };
});

These defaults can also be individually changed by [LocodeCss] and [ExplorerCss] to Customize the Form CSS for specific AutoQuery CRUD Operations.

Customizing Inputs and Fields in Code

All UI attributes are used to populate the metadata model for your App which is used to power their metadata-driven UIs. An alternative way to populate this metadata is to use ConfigureOperation<RequestDto> to populate the metadata directly, e.g. here's an example of how we can customize the <input> in our custom Register FormLayout:

appHost.ConfigureOperation<Register>(op => op.FormLayout = new()
{
    Input.For<Register>(x => x.DisplayName, x => x.Help = "Your first and last name"),
    Input.For<Register>(x => x.Email, x => x.Type = Input.Types.Email),
    Input.For<Register>(x => x.Password, x => x.Type = Input.Types.Password),
    Input.For<Register>(x => x.ConfirmPassword, x => x.Type = Input.Types.Password),
});

Whilst being just a flattened list of inputs we're still able to customize the form layout by using the Typed FieldsPerRow() helper method:

Plugins.Add(new AdminUsersFeature {
    // Add Custom Fields to Create/Edit User Forms
    FormLayout = new() {
        Input.For<UserAuth>(x => x.Email, x => x.Type = Input.Types.Email),
        Input.For<UserAuth>(x => x.UserName),
        Input.For<UserAuth>(x => x.FirstName,    c => c.FieldsPerRow(2)),
        Input.For<UserAuth>(x => x.LastName,     c => c.FieldsPerRow(2)),
        Input.For<UserAuth>(x => x.DisplayName),
        Input.For<UserAuth>(x => x.Company),
        Input.For<UserAuth>(x => x.Address),
        Input.For<UserAuth>(x => x.Address2),
        Input.For<UserAuth>(x => x.City,         c => c.FieldsPerRow(2)),
        Input.For<UserAuth>(x => x.State,        c => c.FieldsPerRow(2)),
        Input.For<UserAuth>(x => x.Country,      c => c.FieldsPerRow(2)),
        Input.For<UserAuth>(x => x.PostalCode,   c => c.FieldsPerRow(2)),
        Input.For<UserAuth>(x => x.PhoneNumber, x => x.Type = Input.Types.Tel),
    }
});

Which just simplifies choosing the responsive TailwindCss grid classes we want, in this case renders 2 fields per row from the sm responsive Tailwind breakpoint by expanding to:

Input.For<UserAuth>(x => x.FirstName, c => c.Input.Css.Field = "col-span-12 sm:col-span-6")

Configure Types

In addition to ConfigureOperation<T> for customizing the metadata for a single operation, there's also ConfigureType<T> for customizing the metadata for a single DTO Type and ConfigureOperations(op => ...) for registering a single lambda to customize the metadata for all Operations and ConfigureTypes(type => ...) to do the same for all DTO Types.

Unlike the AutoGen TypeFilter to Modify Dynamic Types at Runtime which is only executed for customizing code-gen Types, these metadata APIs can be used for customizing the metadata of both Database-First and Code-First Types.

So if the Northwind Database-First and Chinook Code-First were both configured in the same App, you could use a single lambda to configure the metadata in both, e.g:

var icons = new Dictionary<string, ImageInfo>
{
    [nameof(AppUser)] = Svg.CreateImage(Svg.Body.User),
    [nameof(CrudEvent)] = Svg.CreateImage(Svg.Body.History),
    [nameof(UserAuthDetails)] = Svg.CreateImage(Svg.Body.UserDetails),
    [nameof(UserAuthRole)] = Svg.CreateImage(Svg.Body.UserShield),
  
    // Northwind
    ["Category"] = Svg.CreateImage("<path fill='currentColor' d='M20 5h-9.586L8.707 3.293A.997.997 0 0 0 8 3H4c-1.103 0-2 .897-2 2v14c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V7c0-1.103-.897-2-2-2z'/>"),
    //...

    // Chinook
    [nameof(Tracks)] = Svg.CreateImage("<path fill='currentColor' d='M12 3v9.28a4.39 4.39 0 0 0-1.5-.28C8.01 12 6 14.01 6 16.5S8.01 21 10.5 21c2.31 0 4.2-1.75 4.45-4H15V6h4V3h-7z'/>"),
    //...
};

appHost.ConfigureTypes(type => {
    if (icons.TryGetValue(type.Name, out var icon))
        type.Icon = icon;

    if (type.HasNamedConnection("chinook"))
    {
        type.Properties.Each(prop => {
            if (prop.IsPrimaryKey != true && references.TryGetValue(prop.Name, out var refInfo))
                prop.Ref = refInfo;
        });
        
        if (type.Name == nameof(Tracks))
        {
            type.Property(nameof(Tracks.Bytes)).Format = new FormatInfo { Method = FormatMethods.Bytes };
            type.Property(nameof(Tracks.Milliseconds)).Format = new Intl(IntlFormat.DateTime) {
                Minute = DatePart.Digits2,
                Second = DatePart.Digits2,
                FractionalSecondDigits = 3,
            }.ToFormat();
            type.Property(nameof(Tracks.UnitPrice)).Format = new IntlNumber(NumberStyle.Currency) {
                Currency = NumberCurrency.USD
            }.ToFormat();
        }
    }

    switch (type.Name)
    {
        case "Order":
            type.EachProperty(x => x.Name.EndsWith("Date"), x => x.Format = dateFormat);
            type.Property("Freight").Format = currency;
            type.Property("ShipVia").Ref = new() { Model = "Shipper", RefId = "Id", RefLabel = "CompanyName" };
            break;
        case "OrderDetail":
            type.Property("UnitPrice").Format = currency;
            type.Property("Discount").Format = percent;
            break;
        case "Employee":
            type.Property("PhotoPath").Format = new FormatInfo { Method = FormatMethods.IconRounded };
            type.Property("ReportsTo").Ref = new RefInfo { Model = "Employee", RefId = "Id", RefLabel = "LastName" };
            type.ReorderProperty("PhotoPath", before: "Title");
            type.ReorderProperty("ReportsTo", after: "Title");
            break;
        case "EmployeeTerritory":
            type.Property("TerritoryId").Ref = new() { Model = "Territory", RefId = "Id", RefLabel = "TerritoryDescription" };
            break;
        case "Supplier":
        case "Customer":
            type.Property("Phone").Format = new FormatInfo { Method = FormatMethods.LinkPhone };
            type.Property("Fax").Format = new FormatInfo { Method = FormatMethods.LinkPhone };
            break;
    }
});

Typed APIs

One of the benefits of using code to customize inputs is having access to more typed API like Input.Types.Email given they have access to all implementation libraries whereas since ServiceModel DTOs shouldn't reference any implementation assemblies they'll need to use the [Input(Type="email")] string literal instead, although the Typed API can still serve as a reference for the supported input types:

public static class Input
{
    public static class Types
    {
        public const string Text = "text";
        public const string Checkbox = "checkbox";
        public const string Color = "color";
        public const string Date = "date";
        public const string DatetimeLocal = "datetime-local";
        public const string Email = "email";
        public const string File = "file";
        public const string Hidden = "hidden";
        public const string Image = "image";
        public const string Month = "month";
        public const string Number = "number";
        public const string Password = "password";
        public const string Radio = "radio";
        public const string Range = "range";
        public const string Reset = "reset";
        public const string Search = "search";
        public const string Submit = "submit";
        public const string Tel = "tel";
        public const string Time = "time";
        public const string Url = "url";
        public const string Week = "week";
        public const string Select = "select";
        public const string Textarea = "textarea";
    }
    //...
}

Input Reference

All Input customizations serializes down to the InputInfo Metadata DTO which we can see is able to customize most HTML <input> attributes to be able to provide a more optimized UX like client-side validation:

public class InputInfo : IMeta
{
    public string Id { get; set; }
    public string Name { get; set; }
    public string Type { get; set; }
    public string Value { get; set; }
    public string Placeholder { get; set; }
    public string Help { get; set; }
    public string Label { get; set; }
    public string Title { get; set; }
    public string Size { get; set; }
    public string Pattern { get; set; }
    public bool? ReadOnly { get; set; }
    public bool? Required { get; set; }
    public bool? Disabled { get; set; }
    public string Autocomplete { get; set; }
    public string Autofocus  { get; set; }
    public string Min { get; set; }
    public string Max { get; set; }
    public int? Step { get; set; }
    public int? MinLength { get; set; }
    public int? MaxLength { get; set; }
    public string Accept  { get; set; }
    public string Capture  { get; set; }
    public bool? Multiple { get; set; }
    public string[] AllowableValues { get; set; }
    public KeyValuePair<string,string>[] AllowableEntries { get; set; }
    public string Options  { get; set; }
    public bool? Ignore { get; set; }
    public FieldCss Css { get; set; }
}

Table Relations and Modal Lookups

To provide a great UX-Friendly UI suitable for end users, Locode includes a UI solution for linking related data that avoids users from having to deal with raw Foreign Key Ids by instead allowing them to select related records using a rich modal dialog with support for flexible multi column sorting and powerful querying capabilities.

Where possible ServiceStack will populate these UI references when their Data Models are defined with OrmLite's POCO References or follows the {Model}Id naming convention for FK Properties, e.g:

public class JobApplication : AuditBase
{
    [AutoIncrement]
    public int Id { get; set; }

    // Explicit Reference to Job
    [References(typeof(Job))]
    public int JobId { get; set; }
    
    // Implicit Reference to Contact
    public int ContactId { get; set; } 
    //...
}

The effect of which will upgrade the Foreign Key Ids from raw number Inputs to use Modal Lookups to search their related tables where this CRUD API to Create Job Applications:

public class CreateJobApplication : ICreateDb<JobApplication>, IReturn<JobApplication>
{
    [ValidateGreaterThan(0)]
    public int JobId { get; set; }
    
    [ValidateGreaterThan(0)]
    public int ContactId { get; set; }
    
    public DateTime AppliedDate { get; set; }
    
    public JobApplicationStatus ApplicationStatus { get; set; }
    
    [Input(Type = "file"), UploadTo("applications")]
    public List<JobApplicationAttachment> Attachments { get; set; }
}

Renders this Form that uses Modal Lookups for JobId and ContactId properties, a date control for AppliedDate a <select> dropdown for JobApplicationStatus Enum and a multiple File Input control for Attachments

Clicking on the JobId field will launch a Modal Lookup searching the Job table with multi-column sorting and filtering capabilities:

After selecting the related records the lookup fields will be populated with both the first Text column of the table and the ForeignKey Id:

Custom UI References

If our FK References doesn't use explicit references, follows the naming reference convention or we just want to override the UI Reference, we can explicitly define the UI Reference with the [Ref] attribute which in this case will create an Employee Lookup field for the ReportsTo property:

public class Employee
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }

    [Ref(Model = nameof(Employee), RefId = nameof(Id), RefLabel = nameof(LastName))]
    public int ReportsTo { get; set; }
    //...
}

Database-First UI References

Implicit UI References are also populated when using a Database-first approach with Foreign Key columns, table relationships and relevant look up tables reflected in the Locode App, as can be seen in in Locode's Northwind example in its Product, Order and OrderDetails tables.

When the reference can't be inferred we can dynamically add it using AutoQuery's TypeFilter by adding the [Ref] attribute to the Data Model property:

TypeFilter = (type, req) =>
{
    ...
    if (type.Name == "Employee" || type.IsCrudCreateOrUpdate("Employee"))
    {
        ...
        if (type.IsCrud())
        {
            ...
        }
        else if (type.Name == "Employee")
        {
            type.Property("ReportsTo").AddAttribute(
                new RefAttribute { Model = "Employee", RefId = "Id", RefLabel = "LastName" });
        }
    }
}

Which has the same behavior as the code-first [Ref] attribute in enabling the Employee lookup field for the ReportsTo property making it is easy to select the Employee's reporting Manager Id:

When defining UI relationships, RefModel determines the Foreign Key Table used in the Model Lookup, RefId is the target FK Primary Key whilst RefLabel specifies which text field to use to better describe the Record.