Appearance
Appearance
The generated Views and Forms system is designed to reduce the need to implement and maintain custom Vue components in the dashboard, letting you focus on the thing that matters: making the best game possible!
Our hope with this feature is that your developers can focus more on developing and improving game features while we can provide a sensible dashboard View of your custom game data or at least the tools to get there quickly.
Generating a View for a C# type is as simple as putting one of the meta-generated-x
Vue components into your Dashboard code. Of course, the provided typeName
can be an abstract parent class of the actual type, but the input value is assumed to have a $type
property in that case.
// MyCustomComponent.vue
<template lang="pug">
meta-generated-card(
typeName="Game.Logic.MyCustomType"
:value="value"
)
</template>
...
A generated View requires the C# type name of the class and a value to show. If your input value has a $type
property, the typeName
parameter is optional.
The current list of supported Views is
meta-generated-content
meta-generated-card
meta-generated-section
If we take as an example the content of a broadcast and pass it to a meta-generated-section
View, it would look like this:
If we changed the same generated View to a meta-generated-card
View, it would look like this:
If something doesn’t look quite right to you, you can register custom components to override parts or whole sections of the generated tree.
TIP
Pro tip: You can use the generated UI testing page, found in your dashboard at /test/generatedUi
, to preview forms and views for any MetaSerializable
C# type, including your own custom types!
Let’s say we wanted to change how our generated View renders one of the Fields inside our custom mail type that looks something like this:
// MyCustomMail.cs
[MetaSerializableDerived(201)]
[MetaReservedMembers(0, 100)]
public class MailContentWithBanner: MetaInGameMail
{
[MetaValidateRequired]
[MetaMember(1)] public LocalizedString Title { get; private set; }
[MetaFormTextArea]
[MetaMember(2)] public LocalizedString Body { get; private set; }
[MetaMember(3)] public string BannerImage { get; private set; }
// ...
}
The mail contains a LocalizedString
title, a body, and a banner image string that points to an image resource that will be loaded on the client. Usually, a string type Field would be rendered as a simple text Field, but we may want to load and preview the actual image in addition to showing the URL.
Here’s what the broadcast content section with that mail type looks like without any modifications:
To override components, we need a component to show and a filter function for that component. First, let’s create a new component to render our image correctly:
<template lang="pug">
div
div(class="tw-font-bold tw-mb-2") {{ displayName }}
div(class="tw-text-neutral-500") {{ value }}
img(:src="value" class="tw-w-full")
</template>
<script lang="ts" setup>
import { generatedUiFieldBaseProps, useGeneratedUiFieldBase } from '@metaplay/core'
const props = defineProps(generatedUiFieldBaseProps)
const { displayName } = useGeneratedUiFieldBase(props)
</script>
The script part of the component is pretty concise since we are using the ready-made displayName
from useGeneratedUiFieldBase
and the standard props
definition for generated Views.
Now, creating the filter is a bit more tricky. Because our type is a System.String
, we can’t just go replacing every single System.String
Field in the whole dashboard with our new image Field. We need to somehow know that this string Field specifically is supposed to be an image URL. This can be achieved by adding an additional context key to our Field, like so:
// ... MyCustomGameMailType class
[MetaFormFieldContext("imageUrl", true)]
[MetaMember(3)] public string BannerImage { get; private set; }
// ...
And now, we can check for our custom context key in our filter, which we'll to gameSpecific.ts
:
initializationApi.addGeneratedUiViewComponent(
{
filterFunction: (props, type) => {
return type.typeName === 'System.String' &&
props.serverContext?.imageUrl
},
vueComponent: () => import("./ViewImageField.vue")
}
)
The filter checks from the props that our custom imageUrl
context key is present in the serverContext
prop and that the C# type name matches what our component expects, which is System.String
.
Here’s what it looks like with our custom component:
If overriding a single Field is not enough, you can always override entire classes with a generated View. See the advanced usage section of this page for more information.
Generating a Form is almost as easy as generating a View. Just plop down a meta-generated-form
component and pass in the desired C# type name.
<template lang="pug">
meta-generated-form(
typeName="Game.Logic.MyCustomType"
:value="value"
@input="value = $event"
@status="(event) => formValid = $event"
)
</template>
...
An additional change compared to generating a View is the use of v-model
instead of value
and listening to the @status
event to see if the Form is valid or not.
The SDK doesn't currently support all types in the generated Forms, but you can always override a component if it is not rendering how you want.
The process of overriding Form components is similar to overriding View components, but Form components have the added complexity of dealing with outputting the data and performing validation. Let's take a look at how to do it:
<template lang="pug">
<template lang="pug">
div
div(class="tw-font-bold tw-mb-1") {{ displayName }}
MTooltip(v-if="displayHint" :content="displayHint" noUnderline class="tw-ml-2"): MBadge(shape="pill") ?
div(class="tw-flex tw-space-x-2 tw-items-end")
MInputText(
:model-value="value"
@update:model-value="update"
:variant="isValid !== undefined ? isValid ? 'success' : 'danger' : 'default'"
:hint-message="!isValid ? validationError : undefined"
:placeholder="formInputPlaceholder"
:data-testid="dataTestid + '-input'"
class="tw-w-full"
)
img(v-if="value" :src="value" class="tw-w-32" alt="Image preview")
</template>
<script lang="ts" setup>
import { generatedUiFieldFormProps, useGeneratedUiFieldForm, generatedUiFieldFormEmits } from '@metaplay/Core'
import { MTooltip, MBadge, MInputText } from '@metaplay/meta-ui-next'
const props = defineProps(generatedUiFieldFormProps)
const emit = defineEmits(generatedUiFieldFormEmits)
const {
displayName,
displayHint,
formInputPlaceholder,
validationError,
isValid,
update,
dataTestid
} = useGeneratedUiFieldForm(props, emit)
</script>
Most of the magic here comes from the useGeneratedUiFieldForm
function, which provides the following properties here:
displayName
Is the name of the Field and is used as the title in the custom Field.displayHint
Is a hint for the Field, which you can define in C#, with the [MetaFormDisplayProps]
attribute.formInputPlaceholder
Is a placeholder text to show when the Field is empty. You can also define it in C# with the [MetaFormDisplayProps]
attribute.update
is a method that gets bound to the Field's @input
event and will, by default, pass the new value to the parent by emitting its own @input
event.isValid
computed property scans the received validation results from the server validation code and checks if any of them match the current Field.validationError
contains the error message received from the server if the Field has a validation error.dataTestid
is an identifier for this Field for automated testing.Form filters also look very similar:
// Register a component for showing imageUrl Fields in generated forms
initializationApi.addGeneratedUiFormComponent(
{
filterFunction: (props, type) => {
return type.typeName === 'System.String' &&
props.serverContext?.imageUrl
},
vueComponent: () => import('./FormImageField.vue')
}
)
The only difference here is that we call addGeneratedUiFormComponent
instead of addGeneratedUiViewComponent
.
Our new Field used in a Form looks like this:
By default, generated UI visualizes StringId
and MetaRef
types with their keys since that's how they're serialized. Sometimes, however, the keys used in the game's configuration can be quite cryptic, and might not be enough information about what the item points to.
The integration API provides a method to override the show text per StringId
type so that you can avoid this problem.
Let's look at how this is done in Idler for ProducerTypeId
:
// Custom decoration of ProducerTypeId to make it more human-friendly.
initializationApi.addStringIdDecorator('Game.Logic.ProducerTypeId', (stringId: string): string => {
const producer = gameData.gameConfig.Producers[stringId]
const producerKind = gameData.gameConfig.ProducerKinds[producer.kind]
return `${producer.name} (${producerKind.name})`
})
The producer is fetched from the current game configs and turned into a string that consists of the producer's name and its kind.
The generated Forms support server-side Form validation for single Fields and entire classes, and you can easily add it via custom attributes. The current list of validators shipping with Metaplay SDK is minimal but will grow over time.
[MetaValidateRequired]
validates that a Field is not null and, in the case of strings, not empty.[MetaValidateInFuture]
validates that a MetaTime
Field is a time from the future. This is checked against the current server time.[MetaValidateInRange]
validates that an integer Field is between two predetermined values (does not support other number types yet).Implementing your own validators is quite easy, however. Let’s make a new validator for the URL Field we created earlier:
public class UrlValidator : MetaFormValidator<string>
{
public override void Validate(string field, FormValidationContext ctx)
{
if (string.IsNullOrEmpty(field))
{
ctx.Fail("This field is required!");
}
else if (!Uri.IsWellFormedUriString(field, UriKind.Absolute))
{
ctx.Fail("This field must be a valid URL!");
}
}
}
public class ValidateUrlAttribute : MetaFormFieldValidatorBaseAttribute
{
public override string ValidationRuleName => "isUrl";
public override object ValidationRuleProps => null;
public override Type CustomValidatorType => typeof(UrlValidator);
}
Validating a Field requires you to make a class derived from MetaFormValidator
and a custom attribute for that validator.
To use it, we can simply add the attribute to the Field we want to validate.
[ValidateUrl] // Added new validator attribute
[MetaFormFieldContext("imageUrl", true)]
[MetaMember(3)] public string BannerImage { get; private set; }
The generated Forms system will automatically handle the rest.
We could also implement the same validator as a class validator, which would look like this:
public class MailContentWithBannerValidator : MetaFormValidator<MailContentWithBanner>
{
public override void Validate(MailContentWithBanner form, FormValidationContext ctx)
{
if (form == null)
{
ctx.Fail("Content cannot be null!");
return;
}
string bannerField = form.BannerImage;
if (string.IsNullOrEmpty(bannerField))
{
ctx.Fail("This field is required!", nameof(MailContentWithBanner.BannerImage));
}
else if (!Uri.IsWellFormedUriString(bannerField, UriKind.Absolute))
{
ctx.Fail("This field must be a valid URL!", nameof(MailContentWithBanner.BannerImage));
}
}
}
If we want to target the validation error to a Field in the class, we can pass in the name of the Field in the ctx.Fail()
method.
The new class validator can be added using the [MetaFormClassValidator]
attribute:
[MetaFormClassValidator(typeof(MailContentWithBannerValidator))] // Added validator here
[MetaSerializableDerived(201)]
[MetaReservedMembers(0, 100)]
public class MailContentWithBanner : MetaInGameMail
{
// ... class contents
}
With class validators, it’s possible to implement more complex logic, such as one MetaTime
Field having to have a greater value than another (start and end time, for example).
You can implement multiple validators for classes and Fields, all of which will be run when validating a Form. But remember, be careful to always check the values for nulls and empty Fields when validating.
Sometimes you may want to override bigger parts of the generated tree than just a single Field. For example, Let’s look at a simple use-case with the generated mail Form. We’ll override the generated Form Field for the entire MyGameMail
type defined below and replace it with our own implementation.
// Game.Logic.Mail.MyGameMail
[MetaSerializableDerived(202)]
[MetaReservedMembers(0, 100)]
public class MyGameMail : MetaInGameMail
{
[MetaValidateRequired]
[MetaMember(1)] public string Title { get; private set; }
[MetaValidateRequired]
[MetaMember(2)] public string Body { get; private set; }
// ...
}
<template lang="pug">
div
//- Title field
MInputText(
:model-value="value?.title"
@update:model-value="updateTitle"
placeholder="Enter title here..."
:variant="titleValidationError !== undefined ? titleValidationError ? 'success' : 'danger' : 'default'"
:hint-message="!titleValidationError ? titleValidationError : undefined"
)
//- Body field
MInputTextArea(
:model-value="value?.body"
@update:model-value="updateBody"
placeholder="Enter body here..."
:variant="bodyValidationError !== undefined ? bodyValidationError ? 'success' : 'danger' : 'default'"
:hint-message="!bodyValidationError ? bodyValidationError : undefined"
)
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { generatedUiFieldFormProps, useGeneratedUiFieldForm, generatedUiFieldFormEmits } from '@metaplay/Core'
import { MInputText, MInputTextArea } from '@metaplay/meta-ui-next'
// Override default value or the value property.
const props = defineProps({
...generatedUiFieldFormProps,
value: {
type: Object,
default: () => ({
title: '',
body: ''
})
}
})
const emit = defineEmits(generatedUiFieldFormEmits)
const {
update
} = useGeneratedUiFieldForm(props, emit)
// Check server validation results.
const titleValidationError = computed(() =>
props.serverValidationResults?.find((val) => (val.path === 'Title'))?.reason ?? false
)
const bodyValidationError = computed(() =>
props.serverValidationResults?.find((val) => (val.path === 'Body'))?.reason ?? false
)
const updateTitle = function (newTitle: string) {
// Call update to set new value
update({
title: newTitle,
body: props.value?.body
})
}
const updateBody = function (newBody: string) {
// Call update to set new value
update({
title: props.value?.title,
body: newBody
})
}
</script>
Here we are overriding the Form component for the entire MyGameMail
class and providing separate Fields for all the inner Fields of the class. The usage above is straightforward but illustrates the basics of overriding larger parts of the generated tree. The important thing is that the whole class is taken in as the value
property, and even when modifying a single Field, we output the entire value with updated parts using the update
method from useGeneratedUiFieldForm
.
The filter for our new override is very simple:
initializationApi.addGeneratedUiFormComponent(
{
filterFunction: (_props, type) => {
return type.typeName === 'Game.Logic.Mail.MyGameMail'
},
vueComponent: () => import('./MyGameMailField.vue')
}
)
The new Form looks like this with our override:
The props and methods provided by the generatedUiFieldFormProps
and useGeneratedUiFieldForm
can be a bit of a mystery, but the Vue Devtools is your biggest friend when working with the generated Forms system. Here’s a list of the default props and methods it provides you:
fieldInfo
⇒ the current Field we are rendering.fieldInfo: {
fieldName?: "NameOfMyField",
fieldType: "Game.Logic.MyType", // Can also be "[]" for lists and arrays, or "{}" for dictionaries
typeKind: "Class", // See schema section for more information
isLocalized: false, // Whether this field is or contains any localized fields
default: {...} // The default value captured from C#, may not exist
typeParams: [ // Only if the field is of a generic type (array, list or dict etc.)
"Game.Logic.MyType"
],
displayProps: { // This may or may not exist.
displayName: "Overridden display name",
displayHint: "A hint you can show",
placeholder: "Something to show in an empty field..."
},
...any other field decorators
}
value
⇒ the value of the Field or undefined
.previewLocale
⇒ the current locale being shown / edited.fieldSchema
⇒ the type schema object. Can be empty if the type does not have one.gameData
⇒ the current game data that contains the current gameConfig
and serverGameConfig
.staticConfig
⇒ the current static config.editLocales
⇒ the array of locales currently selected for editing.fieldPath
⇒ the Field path used for checking validation results.serverValidationResults
⇒ the array of validation results from the server. It can be undefined.displayName
⇒ display name of the Field. Either from the displayProps
or fieldName
.displayHint
⇒ a hint that can be shown to the user. Either from the displayProps
or undefined
.hasFieldName
⇒ true
if the Field has provided a Field name.formInputPlaceholder
⇒ a placeholder to show in a Form Field. Either from the displayProps
or fieldName
.isValid
⇒ whether any validation results had the same Field path as the current Field. Can be undefined.validationError
⇒ the error message of the matching validation result.dataTestid
⇒ fieldPath
modified to a data-testid for testing.update(newValue)
⇒ fires an @input
event with the provided new valuegetServerValidationError()
⇒ same as validationError
The server provides an API endpoint to fetch a schema for any [MetaSerializable]
tagged type at /api/forms/schema/TypeNameHere
. Let’s take a look at the schema for our custom mail type:
// http://localhost:5550/api/forms/schema/Game.Logic.MailContentWithBanner
{
"typeKind": "Class",
"typeName": "Game.Logic.MailContentWithBanner",
"jsonType": "Game.Logic.MailContentWithBanner, SharedCode",
"isLocalized": true,
"isGeneric": false,
"fields": [
{
"fieldName": "Title",
"fieldType": "Metaplay.Core.Localization.LocalizedString",
"typeKind": "Localized",
"isLocalized": true,
"validationRules": [
{
"type": "notEmpty",
"props": {
"message": "This field is required."
}
}
]
},
{
"fieldName": "Body",
"fieldType": "Metaplay.Core.Localization.LocalizedString",
"typeKind": "Localized",
"isLocalized": true,
"fieldTypeHint": {
"type": "textArea",
"props": {
"rows": 4,
"maxRows": 8
}
}
},
{
"fieldName": "BannerImage",
"fieldType": "System.String",
"typeKind": "Primitive",
"isLocalized": false,
"validationRules": [
{
"type": "isUrl",
"props": null
}
],
"context": [
{
"key": "imageUrl",
"value": true
}
]
}
]
}
The schema can vary a bit depending on its typeKind
, but the schemas we’ll most likely care about are the class-type ones. If we look at the schema for the mail content type, we can see that a lot of the attributes we created made their way into the fields
array. As an example, our cool BannerImage
Field has our custom validation rule inside the validationRules
property and our custom context attribute in the context
property. The Body
Field also has a fieldTypeHint
property added by the [MetaFormTextArea]
attribute. You can create your own attributes called Field decorators, which will also end up here.
Here’s a list of all the possible typeKind
s for reference:
public enum MetaFormContentTypeKind
{
Class,
Enum,
StringId,
DynamicEnum,
Primitive,
Localized,
Abstract,
ValueCollection,
KeyValueCollection,
ConfigLibraryItem,
Nullable,
}
Implementing custom Field decorators is very easy. If you want a Field decorator with a single possible value, make an attribute derived from MetaFormFieldDecoratorBaseAttribute
, and if you want a decorator with multiple possible values, like validationRules
, derive your attribute from MetaFormFieldMultiDecoratorBaseAttribute
. Let’s take a look at an example.
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
public class MetaFormNotEditableAttribute : MetaFormFieldDecoratorBaseAttribute
{
public override string FieldDecoratorKey => "notEditable";
public override object FieldDecoratorValue => true;
}
The above decorator adds the key notEditable
to the Field object, with the value of true
. Added to the BannerImage
Field, the schema would look like this:
...
"fields": [
...
{
"fieldName": "BannerImage",
"fieldType": "System.String",
"typeKind": "Primitive",
"isLocalized": false,
"validationRules": [
{
"type": "isUrl",
"props": null
}
],
"context": [
{
"key": "imageUrl",
"value": true
}
],
"notEditable": true
}
]
The generated Forms system ships with many attributes that change the system's behavior. Here’s a list of the most important ones:
[MetaFormLayoutOrderHint(int order)]
⇒ this attribute can affect the default ordering of the elements. Put the desired order number of the Field to change the sorting of the Field.[MetaFormDeprecated]
⇒ this attribute can be applied to a class to make it not selectable from the drop-down of the different abstract class-derived types.[MetaFormDerivedMembersOnly]
⇒ this attribute can be applied to an abstract or a derived class to ignore any Fields defined in the abstract parent class.[MetaFormDontCaptureDefault]
⇒ this can be applied to a Field to disable the capturing of its default value. By default, all Fields populated with an empty constructor are captured and sent to the Form.[MetaFormNotEditable]
⇒ this can be applied to a Field to make it hidden in Forms.[MetaFormNotViewable]
⇒ same as above but for Views.[MetaFormFieldContext]
⇒ this can be applied to a Field to inject additional context keys and values. Useful when wishing to override specific Fields.[MetaFormDisplayProps]
⇒ this can be applied to a Field to override display properties of the Views and Forms.[MetaFormTextArea]
⇒ this can be applied to a string Field to turn it into a bigger text area in _Forms.[MetaFormRange]
⇒ this can be applied to any number Field to turn it into a range slider.[MetaFormClassValidator]
⇒ this can be applied to a class to add a server-side validator for it.[MetaFormFieldCustomValidator(Type validatorType)]
⇒ this can be applied to Field to add a custom server-side validator for it.The feature is currently in the experimental stage, so some things are going to look ugly or just not work at all. The current list of known limitations is as follows:
Nullable<T>
are not supported.MetaDuration
is not supported.MetaGuid
is not supported.EntityId
is not supported for Forms.typeName="System.Collections.Generic.List<Metaplay.Core.InGameMail.PlayerMailItem>"
)F32Vec3
, F64Vec3
, IntVector3
...)The following pages might be of interest to you next:
Happy developing!