From bb86113e4770f01990bb2f9e50a0e60f35652be0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 Aug 2025 15:26:20 +0000 Subject: [PATCH 1/5] Initial plan From 003f5d23b6369e24c82f85e2929ab9a0ba8427a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 Aug 2025 15:42:01 +0000 Subject: [PATCH 2/5] Implement JsonStringEnumConverter support for enums Co-authored-by: ascott18 <5017521+ascott18@users.noreply.github.com> --- .../Generators/Scripts/TsModels.cs | 14 ++++- .../Utils/VueType.cs | 4 +- .../TargetClasses/TestDbContext/StringEnum.cs | 34 ++++++++++++ .../TestDbContext/TestDbContext.cs | 2 + .../EnumStringSerializationTests.cs | 54 +++++++++++++++++++ .../TypeDefinition/TypeViewModel.cs | 8 +++ 6 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/StringEnum.cs create mode 100644 src/IntelliTect.Coalesce.Tests/Tests/TypeDefinition/EnumStringSerializationTests.cs diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsModels.cs b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsModels.cs index abd3c6c38..cf2776979 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsModels.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsModels.cs @@ -25,12 +25,24 @@ public override Task BuildOutputAsync() foreach (var model in Model.ClientEnums.OrderBy(e => e.ClientTypeName)) { + // Check if this enum should be serialized as strings + var isStringEnum = model.IsEnumStringSerializable; + using (b.Block($"export enum {model.ClientTypeName}")) { foreach (var value in model.EnumValues) { b.DocComment(value.Comment ?? value.Description); - b.Line($"{value.Name} = {value.Value},"); + if (isStringEnum) + { + // String enum: assign string literals + b.Line($"{value.Name} = \"{value.Name}\","); + } + else + { + // Numeric enum: assign numeric values + b.Line($"{value.Name} = {value.Value},"); + } } } diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Utils/VueType.cs b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Utils/VueType.cs index 9f1b7ee16..f6cd2a62f 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Utils/VueType.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Utils/VueType.cs @@ -35,7 +35,9 @@ private string TsTypePlain(TypeViewModel type, string modelPrefix, bool viewMode case TypeDiscriminator.String: return "string"; case TypeDiscriminator.Boolean: return "boolean"; case TypeDiscriminator.Date: return "Date"; - case TypeDiscriminator.Enum: return modelPrefix + type.NullableValueUnderlyingType.ClientTypeName; + case TypeDiscriminator.Enum: + // Check if enum should be serialized as string + return type.IsEnumStringSerializable ? "string" : modelPrefix + type.NullableValueUnderlyingType.ClientTypeName; case TypeDiscriminator.Number: return "number"; case TypeDiscriminator.Void: return "void"; case TypeDiscriminator.Unknown: return "unknown"; 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; } From 5fdb6c369c452f756a30e93ae360ff0567059186 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 Aug 2025 15:47:23 +0000 Subject: [PATCH 3/5] Complete implementation and validation of JsonStringEnumConverter support Co-authored-by: ascott18 <5017521+ascott18@users.noreply.github.com> --- src/test-targets/api-clients.g.ts | 25 +++++ src/test-targets/metadata.g.ts | 154 +++++++++++++++++++++++++++++- src/test-targets/models.g.ts | 44 +++++++++ src/test-targets/viewmodels.g.ts | 46 +++++++++ 4 files changed, 268 insertions(+), 1 deletion(-) 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..b437e0f4c 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,28 @@ export const Statuses = domain.enums.Statuses = { }, ]), } +export const StringSerializedEnum = domain.enums.StringSerializedEnum = { + name: "StringSerializedEnum" as const, + displayName: "String Serialized 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 Titles = domain.enums.Titles = { name: "Titles" as const, displayName: "Titles", @@ -1198,7 +1242,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 +2421,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 +4338,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 +5331,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 +5379,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..bbe1f463c 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 = "FirstValue", + SecondValue = "SecondValue", + ThirdValue = "ThirdValue", +} + + export enum Titles { Mr = 0, Ms = 1, @@ -884,6 +898,33 @@ export class Sibling { } +export interface StringEnumModel extends Model { + id: number | null + stringEnum: string | null + regularEnum: RegularEnum | null + nullableStringEnum: string | 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..8c9d38f93 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: string | null; + regularEnum: $models.RegularEnum | null; + nullableStringEnum: string | 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, From 33446a795e801854bb88eb6c77861a29505ef7d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 Aug 2025 16:17:58 +0000 Subject: [PATCH 4/5] Fix enum string serialization approach per feedback - keep client-side enums numeric with transparent conversion Co-authored-by: ascott18 <5017521+ascott18@users.noreply.github.com> --- .../Generators/Scripts/TsMetadata.cs | 6 +++ .../Generators/Scripts/TsModels.cs | 14 +----- .../Utils/VueType.cs | 4 +- src/coalesce-vue/src/metadata.ts | 6 +++ src/coalesce-vue/src/model.ts | 44 ++++++++++++++++++- src/test-targets/metadata.g.ts | 1 + src/test-targets/models.g.ts | 10 ++--- src/test-targets/viewmodels.g.ts | 4 +- 8 files changed, 64 insertions(+), 25 deletions(-) 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.CodeGeneration.Vue/Generators/Scripts/TsModels.cs b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsModels.cs index cf2776979..abd3c6c38 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsModels.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsModels.cs @@ -25,24 +25,12 @@ public override Task BuildOutputAsync() foreach (var model in Model.ClientEnums.OrderBy(e => e.ClientTypeName)) { - // Check if this enum should be serialized as strings - var isStringEnum = model.IsEnumStringSerializable; - using (b.Block($"export enum {model.ClientTypeName}")) { foreach (var value in model.EnumValues) { b.DocComment(value.Comment ?? value.Description); - if (isStringEnum) - { - // String enum: assign string literals - b.Line($"{value.Name} = \"{value.Name}\","); - } - else - { - // Numeric enum: assign numeric values - b.Line($"{value.Name} = {value.Value},"); - } + b.Line($"{value.Name} = {value.Value},"); } } diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Utils/VueType.cs b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Utils/VueType.cs index f6cd2a62f..9f1b7ee16 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Utils/VueType.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Utils/VueType.cs @@ -35,9 +35,7 @@ private string TsTypePlain(TypeViewModel type, string modelPrefix, bool viewMode case TypeDiscriminator.String: return "string"; case TypeDiscriminator.Boolean: return "boolean"; case TypeDiscriminator.Date: return "Date"; - case TypeDiscriminator.Enum: - // Check if enum should be serialized as string - return type.IsEnumStringSerializable ? "string" : modelPrefix + type.NullableValueUnderlyingType.ClientTypeName; + case TypeDiscriminator.Enum: return modelPrefix + type.NullableValueUnderlyingType.ClientTypeName; case TypeDiscriminator.Number: return "number"; case TypeDiscriminator.Void: return "void"; case TypeDiscriminator.Unknown: return "unknown"; 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/metadata.g.ts b/src/test-targets/metadata.g.ts index b437e0f4c..5b0bc02d3 100644 --- a/src/test-targets/metadata.g.ts +++ b/src/test-targets/metadata.g.ts @@ -133,6 +133,7 @@ export const StringSerializedEnum = domain.enums.StringSerializedEnum = { name: "StringSerializedEnum" as const, displayName: "String Serialized Enum", type: "enum", + serializeAsString: true, ...getEnumMeta<"FirstValue"|"SecondValue"|"ThirdValue">([ { value: 1, diff --git a/src/test-targets/models.g.ts b/src/test-targets/models.g.ts index bbe1f463c..187b51eb0 100644 --- a/src/test-targets/models.g.ts +++ b/src/test-targets/models.g.ts @@ -47,9 +47,9 @@ export enum Statuses { export enum StringSerializedEnum { - FirstValue = "FirstValue", - SecondValue = "SecondValue", - ThirdValue = "ThirdValue", + FirstValue = 1, + SecondValue = 2, + ThirdValue = 3, } @@ -900,9 +900,9 @@ export class Sibling { export interface StringEnumModel extends Model { id: number | null - stringEnum: string | null + stringEnum: StringSerializedEnum | null regularEnum: RegularEnum | null - nullableStringEnum: string | null + nullableStringEnum: StringSerializedEnum | null } export class StringEnumModel { diff --git a/src/test-targets/viewmodels.g.ts b/src/test-targets/viewmodels.g.ts index 8c9d38f93..2d200832d 100644 --- a/src/test-targets/viewmodels.g.ts +++ b/src/test-targets/viewmodels.g.ts @@ -1460,9 +1460,9 @@ export class StandaloneReadWriteListViewModel extends ListViewModel<$models.Stan export interface StringEnumModelViewModel extends $models.StringEnumModel { id: number | null; - stringEnum: string | null; + stringEnum: $models.StringSerializedEnum | null; regularEnum: $models.RegularEnum | null; - nullableStringEnum: string | null; + nullableStringEnum: $models.StringSerializedEnum | null; } export class StringEnumModelViewModel extends ViewModel<$models.StringEnumModel, $apiClients.StringEnumModelApiClient, number> implements $models.StringEnumModel { From c2d12821694624b4c0268fe22aa8147ce0864a53 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 Aug 2025 03:51:26 +0000 Subject: [PATCH 5/5] docs: Add documentation for JsonStringEnumConverter feature (#458) Co-authored-by: ascott18 <5017521+ascott18@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/modeling/model-components/properties.md | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) 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.