diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e8f93be7..796c0763e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ - `c-select`: The `create` prop now supports a `position` property to control whether the create item appears at the start ('start', default) or end ('end') of the dropdown list. - `c-select-many-to-many`: The `itemTitle` prop now receives the existing selected middle entity instance, if there is one. - `c-loader-status`: Added `show-success` prop and flag to display success messages when operations complete successfully. +- Support for enums annotated with `[JsonConverter(typeof(JsonStringEnumConverter))]` to be serialized as strings in APIs while maintaining numeric enum behavior on the client-side through transparent conversion. ## Fixes - Fix error in codegen when using JS reserved keywords or C# contextual keywords as parameter names. diff --git a/docs/modeling/model-components/properties.md b/docs/modeling/model-components/properties.md index 09f43cf13..81882c5d2 100644 --- a/docs/modeling/model-components/properties.md +++ b/docs/modeling/model-components/properties.md @@ -12,6 +12,22 @@ The following kinds of properties may be declared on your models. Most common built-in primitive (numerics, strings, booleans) and other scalar data types (enums, [date types](/topics/working-with-dates.md), `Guid`, `Uri`), and their nullable variants, are all supported as model properties. Collections of these types are also supported. +#### Enum String Serialization + +Enums can be serialized as strings in API responses by annotating them with `[JsonConverter(typeof(JsonStringEnumConverter))]`: + +```c# +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Status +{ + Active = 1, + Inactive = 2, + Pending = 3 +} +``` + +When this attribute is present, the enum values will be sent as strings (e.g., `"Active"`) in JSON responses instead of numbers. The generated TypeScript enums and ViewModels continue to use numeric values on the client-side, with transparent conversion handled automatically by Coalesce's serialization layer. + ### Non-mapped POCOs Properties of a type that are not on your `DbContext` will also have corresponding properties generated on the [TypeScript ViewModels](/stacks/vue/layers/viewmodels.md#generated-members) typed as [Plain Models](/stacks/vue/layers/models.md), and the values of such properties will be sent with the object to the client when requested. Properties of this type will also be sent back to the server by the client when they are encountered. diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs index ac31aa4f2..d20cfaf6e 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs @@ -185,6 +185,12 @@ private void WriteEnumMetadata(TypeScriptCodeBuilder b, TypeViewModel model) b.StringProp("name", model.ClientTypeName, asConst: true); b.StringProp("displayName", model.DisplayName); b.StringProp("type", "enum"); + + // Add flag for string serialization if this enum has JsonStringEnumConverter + if (model.IsEnumStringSerializable) + { + b.Prop("serializeAsString", "true"); + } string enumShape = string.Join("|", model.EnumValues.Select(ev => $"\"{ev.Name}\"")); b.Line($"...getEnumMeta<{enumShape}>(["); diff --git a/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/StringEnum.cs b/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/StringEnum.cs new file mode 100644 index 000000000..2c91eef94 --- /dev/null +++ b/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/StringEnum.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace IntelliTect.Coalesce.Tests.TargetClasses.TestDbContext; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum StringSerializedEnum +{ + [Display(Name = "First Value")] + FirstValue = 1, + + [Display(Name = "Second Value")] + SecondValue = 2, + + ThirdValue = 3 +} + +public enum RegularEnum +{ + FirstValue = 1, + SecondValue = 2, + ThirdValue = 3 +} + +public class StringEnumModel +{ + public int Id { get; set; } + + public StringSerializedEnum StringEnum { get; set; } + + public RegularEnum RegularEnum { get; set; } + + public StringSerializedEnum? NullableStringEnum { get; set; } +} \ No newline at end of file diff --git a/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/TestDbContext.cs b/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/TestDbContext.cs index 8277ae594..78537bb30 100644 --- a/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/TestDbContext.cs +++ b/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/TestDbContext.cs @@ -44,6 +44,8 @@ public class AppDbContext : DbContext public DbSet MultipleParents { get; set; } public DbSet Parent1s { get; set; } public DbSet Parent2s { get; set; } + + public DbSet StringEnumModels { get; set; } public AppDbContext() : this(Guid.NewGuid().ToString()) { } diff --git a/src/IntelliTect.Coalesce.Tests/Tests/TypeDefinition/EnumStringSerializationTests.cs b/src/IntelliTect.Coalesce.Tests/Tests/TypeDefinition/EnumStringSerializationTests.cs new file mode 100644 index 000000000..fe8ace841 --- /dev/null +++ b/src/IntelliTect.Coalesce.Tests/Tests/TypeDefinition/EnumStringSerializationTests.cs @@ -0,0 +1,54 @@ +using IntelliTect.Coalesce.Tests.TargetClasses.TestDbContext; +using IntelliTect.Coalesce.Tests.Util; +using IntelliTect.Coalesce.TypeDefinition; +using Xunit; + +namespace IntelliTect.Coalesce.Tests.Tests.TypeDefinition +{ + public class EnumStringSerializationTests + { + public EnumStringSerializationTests() + { + ReflectionRepositoryFactory.Initialize(); + } + + [Fact] + public void IsEnumStringSerializable_WithJsonStringEnumConverter_ReturnsTrue() + { + // Arrange + var enumType = ReflectionRepository.Global.GetOrAddType(typeof(StringSerializedEnum)); + + // Act & Assert + Assert.True(enumType.IsEnum); + Assert.True(enumType.IsEnumStringSerializable); + } + + [Fact] + public void IsEnumStringSerializable_WithoutJsonStringEnumConverter_ReturnsFalse() + { + // Arrange + var enumType = ReflectionRepository.Global.GetOrAddType(typeof(RegularEnum)); + + // Act & Assert + Assert.True(enumType.IsEnum); + Assert.False(enumType.IsEnumStringSerializable); + } + + [Fact] + public void StringEnumModel_Properties_DetectCorrectSerializationType() + { + // Arrange + var classViewModel = ReflectionRepository.Global.GetClassViewModel(); + + // Act + var stringEnumProperty = classViewModel.PropertyByName(nameof(StringEnumModel.StringEnum)); + var regularEnumProperty = classViewModel.PropertyByName(nameof(StringEnumModel.RegularEnum)); + var nullableStringEnumProperty = classViewModel.PropertyByName(nameof(StringEnumModel.NullableStringEnum)); + + // Assert + Assert.True(stringEnumProperty.Type.NullableValueUnderlyingType.IsEnumStringSerializable); + Assert.False(regularEnumProperty.Type.NullableValueUnderlyingType.IsEnumStringSerializable); + Assert.True(nullableStringEnumProperty.Type.NullableValueUnderlyingType.IsEnumStringSerializable); + } + } +} \ No newline at end of file diff --git a/src/IntelliTect.Coalesce/TypeDefinition/TypeViewModel.cs b/src/IntelliTect.Coalesce/TypeDefinition/TypeViewModel.cs index 08455a494..ced6376aa 100644 --- a/src/IntelliTect.Coalesce/TypeDefinition/TypeViewModel.cs +++ b/src/IntelliTect.Coalesce/TypeDefinition/TypeViewModel.cs @@ -81,6 +81,14 @@ internal TypeViewModel(ReflectionRepository? reflectionRepository) : this() public abstract bool IsEnum { get; } + /// + /// Returns true if this enum type is annotated with JsonConverter(typeof(JsonStringEnumConverter)) + /// and should be serialized as strings instead of numeric values. + /// + public bool IsEnumStringSerializable => IsEnum && + this.HasAttribute() && + this.GetAttributeValue(a => a.ConverterType)?.FullyQualifiedName == "System.Text.Json.Serialization.JsonStringEnumConverter"; + public abstract TypeViewModel? FirstTypeArgument { get; } public abstract TypeViewModel? ArrayType { get; } diff --git a/src/coalesce-vue/src/metadata.ts b/src/coalesce-vue/src/metadata.ts index 1a5b8590b..3fd56ee7c 100644 --- a/src/coalesce-vue/src/metadata.ts +++ b/src/coalesce-vue/src/metadata.ts @@ -228,6 +228,12 @@ export interface EnumType extends Metadata { */ readonly values: EnumMember[]; + /** + * True if this enum should be serialized as strings instead of numbers for JSON. + * This is set when the enum has JsonStringEnumConverter attribute. + */ + readonly serializeAsString?: boolean; + // TODO: support [Flags] enums? Would need special handling for displaying/editing, and a flag on the metadata to flag it as [Flags] } diff --git a/src/coalesce-vue/src/model.ts b/src/coalesce-vue/src/model.ts index c2b9a2429..399ed1cf9 100644 --- a/src/coalesce-vue/src/model.ts +++ b/src/coalesce-vue/src/model.ts @@ -208,7 +208,6 @@ export function parseValue( switch (meta.type) { case "number": - case "enum": if (type === "number") return value; if (type !== "string") { @@ -230,6 +229,36 @@ export function parseValue( } return parsed; + case "enum": + if (type === "number") return value; + + if (type !== "string") { + // We don't want to parse things like booleans into numbers. + // Strings are all we should be handling. + throw parseError(value, meta); + } + + // Parse empty/blank as null, not zero. + if (isNullOrWhitespace(value)) { + return null; + } + + // If this enum is string-serialized and we have a string value, + // look up the corresponding numeric value + if (meta.typeDef.serializeAsString) { + const enumMember = meta.typeDef.valueLookup[value]; + if (enumMember) { + return enumMember.value; + } + // If we can't find the string value, fall through to numeric parsing + } + + const enumParsed = Number(value); + if (isNaN(enumParsed)) { + throw parseError(value, meta); + } + return enumParsed; + case "string": if (type === "string") return value; if (type === "object") throw parseError(value, meta); @@ -691,7 +720,18 @@ class MapToDtoVisitor extends Visitor< } protected visitEnumValue(value: any, meta: EnumValue) { - return parseValue(value, meta); + const parsed = parseValue(value, meta); + if (parsed == null) return null; + + // If this enum should be serialized as strings, convert the numeric value to string + if (meta.typeDef.serializeAsString) { + const enumMember = meta.typeDef.valueLookup[parsed]; + if (enumMember) { + return enumMember.strValue; + } + } + + return parsed; } protected visitUnknownValue(value: any, meta: UnknownValue) { diff --git a/src/test-targets/api-clients.g.ts b/src/test-targets/api-clients.g.ts index cd97f0469..a799bf1c9 100644 --- a/src/test-targets/api-clients.g.ts +++ b/src/test-targets/api-clients.g.ts @@ -369,6 +369,26 @@ export class ComplexModelApiClient extends ModelApiClient<$models.ComplexModel> } + public downloadAttachment_VaryStringCustomCache(id: number | null, etag?: string | null, $config?: AxiosRequestConfig): AxiosPromise> { + const $method = this.$metadata.methods.downloadAttachment_VaryStringCustomCache + const $params = { + id, + etag, + } + return this.$invoke($method, $params, $config) + } + + + public downloadAttachment_VaryStringNoCache(id: number | null, etag?: string | null, $config?: AxiosRequestConfig): AxiosPromise> { + const $method = this.$metadata.methods.downloadAttachment_VaryStringNoCache + const $params = { + id, + etag, + } + return this.$invoke($method, $params, $config) + } + + public downloadAttachment_VaryInt(id: number | null, etag?: number | null, $config?: AxiosRequestConfig): AxiosPromise> { const $method = this.$metadata.methods.downloadAttachment_VaryInt const $params = { @@ -711,6 +731,11 @@ export class StandaloneReadWriteApiClient extends ModelApiClient<$models.Standal } +export class StringEnumModelApiClient extends ModelApiClient<$models.StringEnumModel> { + constructor() { super($metadata.StringEnumModel) } +} + + export class StringIdentityApiClient extends ModelApiClient<$models.StringIdentity> { constructor() { super($metadata.StringIdentity) } } diff --git a/src/test-targets/metadata.g.ts b/src/test-targets/metadata.g.ts index a4a31f321..5b0bc02d3 100644 --- a/src/test-targets/metadata.g.ts +++ b/src/test-targets/metadata.g.ts @@ -51,6 +51,28 @@ export const Genders = domain.enums.Genders = { }, ]), } +export const RegularEnum = domain.enums.RegularEnum = { + name: "RegularEnum" as const, + displayName: "Regular Enum", + type: "enum", + ...getEnumMeta<"FirstValue"|"SecondValue"|"ThirdValue">([ + { + value: 1, + strValue: "FirstValue", + displayName: "First Value", + }, + { + value: 2, + strValue: "SecondValue", + displayName: "Second Value", + }, + { + value: 3, + strValue: "ThirdValue", + displayName: "Third Value", + }, + ]), +} export const SkyConditions = domain.enums.SkyConditions = { name: "SkyConditions" as const, displayName: "Sky Conditions", @@ -107,6 +129,29 @@ export const Statuses = domain.enums.Statuses = { }, ]), } +export const StringSerializedEnum = domain.enums.StringSerializedEnum = { + name: "StringSerializedEnum" as const, + displayName: "String Serialized Enum", + type: "enum", + serializeAsString: true, + ...getEnumMeta<"FirstValue"|"SecondValue"|"ThirdValue">([ + { + value: 1, + strValue: "FirstValue", + displayName: "First Value", + }, + { + value: 2, + strValue: "SecondValue", + displayName: "Second Value", + }, + { + value: 3, + strValue: "ThirdValue", + displayName: "Third Value", + }, + ]), +} export const Titles = domain.enums.Titles = { name: "Titles" as const, displayName: "Titles", @@ -1198,7 +1243,7 @@ export const ComplexModel = domain.types.ComplexModel = { restrictedString: { name: "restrictedString", displayName: "Restricted String", - description: "This is a multiline string in an attribute.\r\nThis is a second line in the string.", + description: "This is a multiline string in an attribute.\nThis is a second line in the string.", type: "string", role: "value", }, @@ -2377,6 +2422,68 @@ export const ComplexModel = domain.types.ComplexModel = { role: "value", }, }, + downloadAttachment_VaryStringCustomCache: { + name: "downloadAttachment_VaryStringCustomCache", + displayName: "Download Attachment _ Vary String Custom Cache", + transportType: "item", + httpMethod: "GET", + params: { + id: { + name: "id", + displayName: "Primary Key", + type: "number", + role: "value", + get source() { return (domain.types.ComplexModel as ModelType & { name: "ComplexModel" }).props.complexModelId }, + rules: { + required: val => val != null || "Primary Key is required.", + } + }, + etag: { + name: "etag", + displayName: "Etag", + type: "string", + role: "value", + get source() { return (domain.types.ComplexModel as ModelType & { name: "ComplexModel" }).props.name }, + }, + }, + return: { + name: "$return", + displayName: "Result", + type: "file", + role: "value", + }, + }, + downloadAttachment_VaryStringNoCache: { + name: "downloadAttachment_VaryStringNoCache", + displayName: "Download Attachment _ Vary String No Cache", + transportType: "item", + httpMethod: "GET", + params: { + id: { + name: "id", + displayName: "Primary Key", + type: "number", + role: "value", + get source() { return (domain.types.ComplexModel as ModelType & { name: "ComplexModel" }).props.complexModelId }, + rules: { + required: val => val != null || "Primary Key is required.", + } + }, + etag: { + name: "etag", + displayName: "Etag", + type: "string", + role: "value", + get source() { return (domain.types.ComplexModel as ModelType & { name: "ComplexModel" }).props.name }, + }, + }, + return: { + name: "$return", + displayName: "Result", + type: "file", + role: "value", + }, + }, downloadAttachment_VaryInt: { name: "downloadAttachment_VaryInt", displayName: "Download Attachment _ Vary Int", @@ -4232,6 +4339,49 @@ export const StandaloneReadWrite = domain.types.StandaloneReadWrite = { }, }, } +export const StringEnumModel = domain.types.StringEnumModel = { + name: "StringEnumModel" as const, + displayName: "String Enum Model", + get displayProp() { return this.props.id }, + type: "model", + controllerRoute: "StringEnumModel", + get keyProp() { return this.props.id }, + behaviorFlags: 7 as BehaviorFlags, + props: { + id: { + name: "id", + displayName: "Id", + type: "number", + role: "primaryKey", + hidden: 3 as HiddenAreas, + }, + stringEnum: { + name: "stringEnum", + displayName: "String Enum", + type: "enum", + get typeDef() { return StringSerializedEnum }, + role: "value", + }, + regularEnum: { + name: "regularEnum", + displayName: "Regular Enum", + type: "enum", + get typeDef() { return RegularEnum }, + role: "value", + }, + nullableStringEnum: { + name: "nullableStringEnum", + displayName: "Nullable String Enum", + type: "enum", + get typeDef() { return StringSerializedEnum }, + role: "value", + }, + }, + methods: { + }, + dataSources: { + }, +} export const StringIdentity = domain.types.StringIdentity = { name: "StringIdentity" as const, displayName: "String Identity", @@ -5182,8 +5332,10 @@ interface AppDomain extends Domain { enums: { EnumPkId: typeof EnumPkId Genders: typeof Genders + RegularEnum: typeof RegularEnum SkyConditions: typeof SkyConditions Statuses: typeof Statuses + StringSerializedEnum: typeof StringSerializedEnum Titles: typeof Titles } types: { @@ -5228,6 +5380,7 @@ interface AppDomain extends Domain { Sibling: typeof Sibling StandaloneReadonly: typeof StandaloneReadonly StandaloneReadWrite: typeof StandaloneReadWrite + StringEnumModel: typeof StringEnumModel StringIdentity: typeof StringIdentity Test: typeof Test ValidationTarget: typeof ValidationTarget diff --git a/src/test-targets/models.g.ts b/src/test-targets/models.g.ts index 70e3ef4a9..187b51eb0 100644 --- a/src/test-targets/models.g.ts +++ b/src/test-targets/models.g.ts @@ -17,6 +17,13 @@ export enum Genders { } +export enum RegularEnum { + FirstValue = 1, + SecondValue = 2, + ThirdValue = 3, +} + + export enum SkyConditions { Cloudy = 0, PartyCloudy = 1, @@ -39,6 +46,13 @@ export enum Statuses { } +export enum StringSerializedEnum { + FirstValue = 1, + SecondValue = 2, + ThirdValue = 3, +} + + export enum Titles { Mr = 0, Ms = 1, @@ -884,6 +898,33 @@ export class Sibling { } +export interface StringEnumModel extends Model { + id: number | null + stringEnum: StringSerializedEnum | null + regularEnum: RegularEnum | null + nullableStringEnum: StringSerializedEnum | null +} +export class StringEnumModel { + + /** Mutates the input object and its descendants into a valid StringEnumModel implementation. */ + static convert(data?: Partial): StringEnumModel { + return convertToModel(data || {}, metadata.StringEnumModel) + } + + /** Maps the input object and its descendants to a new, valid StringEnumModel implementation. */ + static map(data?: Partial): StringEnumModel { + return mapToModel(data || {}, metadata.StringEnumModel) + } + + static [Symbol.hasInstance](x: any) { return x?.$metadata === metadata.StringEnumModel; } + + /** Instantiate a new StringEnumModel, optionally basing it on the given data. */ + constructor(data?: Partial | {[k: string]: any}) { + Object.assign(this, StringEnumModel.map(data || {})); + } +} + + export interface StringIdentity extends Model { stringIdentityId: string | null parentId: string | null @@ -1514,8 +1555,10 @@ declare module "coalesce-vue/lib/model" { interface EnumTypeLookup { EnumPkId: EnumPkId Genders: Genders + RegularEnum: RegularEnum SkyConditions: SkyConditions Statuses: Statuses + StringSerializedEnum: StringSerializedEnum Titles: Titles } interface ModelTypeLookup { @@ -1560,6 +1603,7 @@ declare module "coalesce-vue/lib/model" { Sibling: Sibling StandaloneReadonly: StandaloneReadonly StandaloneReadWrite: StandaloneReadWrite + StringEnumModel: StringEnumModel StringIdentity: StringIdentity Test: Test ValidationTarget: ValidationTarget diff --git a/src/test-targets/viewmodels.g.ts b/src/test-targets/viewmodels.g.ts index e23826f4f..2d200832d 100644 --- a/src/test-targets/viewmodels.g.ts +++ b/src/test-targets/viewmodels.g.ts @@ -648,6 +648,28 @@ export class ComplexModelViewModel extends ViewModel<$models.ComplexModel, $apiC return downloadAttachment_VaryString } + public get downloadAttachment_VaryStringCustomCache() { + const downloadAttachment_VaryStringCustomCache = this.$apiClient.$makeCaller( + this.$metadata.methods.downloadAttachment_VaryStringCustomCache, + (c) => c.downloadAttachment_VaryStringCustomCache(this.$primaryKey, this.name), + () => ({}), + (c, args) => c.downloadAttachment_VaryStringCustomCache(this.$primaryKey, this.name)) + + Object.defineProperty(this, 'downloadAttachment_VaryStringCustomCache', {value: downloadAttachment_VaryStringCustomCache}); + return downloadAttachment_VaryStringCustomCache + } + + public get downloadAttachment_VaryStringNoCache() { + const downloadAttachment_VaryStringNoCache = this.$apiClient.$makeCaller( + this.$metadata.methods.downloadAttachment_VaryStringNoCache, + (c) => c.downloadAttachment_VaryStringNoCache(this.$primaryKey, this.name), + () => ({}), + (c, args) => c.downloadAttachment_VaryStringNoCache(this.$primaryKey, this.name)) + + Object.defineProperty(this, 'downloadAttachment_VaryStringNoCache', {value: downloadAttachment_VaryStringNoCache}); + return downloadAttachment_VaryStringNoCache + } + public get downloadAttachment_VaryInt() { const downloadAttachment_VaryInt = this.$apiClient.$makeCaller( this.$metadata.methods.downloadAttachment_VaryInt, @@ -1436,6 +1458,28 @@ export class StandaloneReadWriteListViewModel extends ListViewModel<$models.Stan } +export interface StringEnumModelViewModel extends $models.StringEnumModel { + id: number | null; + stringEnum: $models.StringSerializedEnum | null; + regularEnum: $models.RegularEnum | null; + nullableStringEnum: $models.StringSerializedEnum | null; +} +export class StringEnumModelViewModel extends ViewModel<$models.StringEnumModel, $apiClients.StringEnumModelApiClient, number> implements $models.StringEnumModel { + + constructor(initialData?: DeepPartial<$models.StringEnumModel> | null) { + super($metadata.StringEnumModel, new $apiClients.StringEnumModelApiClient(), initialData) + } +} +defineProps(StringEnumModelViewModel, $metadata.StringEnumModel) + +export class StringEnumModelListViewModel extends ListViewModel<$models.StringEnumModel, $apiClients.StringEnumModelApiClient, StringEnumModelViewModel> { + + constructor() { + super($metadata.StringEnumModel, new $apiClients.StringEnumModelApiClient()) + } +} + + export interface StringIdentityViewModel extends $models.StringIdentity { stringIdentityId: string | null; parentId: string | null; @@ -1569,6 +1613,7 @@ const viewModelTypeLookup = ViewModel.typeLookup = { Sibling: SiblingViewModel, StandaloneReadonly: StandaloneReadonlyViewModel, StandaloneReadWrite: StandaloneReadWriteViewModel, + StringEnumModel: StringEnumModelViewModel, StringIdentity: StringIdentityViewModel, Test: TestViewModel, ZipCode: ZipCodeViewModel, @@ -1600,6 +1645,7 @@ const listViewModelTypeLookup = ListViewModel.typeLookup = { Sibling: SiblingListViewModel, StandaloneReadonly: StandaloneReadonlyListViewModel, StandaloneReadWrite: StandaloneReadWriteListViewModel, + StringEnumModel: StringEnumModelListViewModel, StringIdentity: StringIdentityListViewModel, Test: TestListViewModel, ZipCode: ZipCodeListViewModel,