From e4701123ae85ef2b7586801d09fd191ef965d451 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Wed, 15 Nov 2023 16:11:11 -0800 Subject: [PATCH] refactor: move default value population to viewmodel ctor in order to mark defaulted props as dirty and to not interfere with API responses --- docs/modeling/model-components/attributes.md | 3 +- .../Coalesce.Web.Vue2/src/metadata.g.ts | 1 + .../Generators/Scripts/TsMetadata.cs | 1463 +++++++++-------- .../TestDbContext/ComplexModel.cs | 13 + .../TypeDefinition/PropertyViewModel.cs | 6 +- src/coalesce-vue/src/metadata.ts | 5 +- src/coalesce-vue/src/model.ts | 9 +- src/coalesce-vue/src/viewmodel.ts | 17 +- src/coalesce-vue/test/targets.metadata.ts | 17 +- src/coalesce-vue/test/viewmodel.spec.ts | 73 + 10 files changed, 861 insertions(+), 746 deletions(-) diff --git a/docs/modeling/model-components/attributes.md b/docs/modeling/model-components/attributes.md index 84bac4bfe..e82863afb 100644 --- a/docs/modeling/model-components/attributes.md +++ b/docs/modeling/model-components/attributes.md @@ -42,7 +42,6 @@ Properties with `[MaxLength]` will generate [client validation](/modeling/model- Some values of `DataType` when provided to `DataTypeAttribute` on a `string` property will alter the behavior of the [Vue Components](/stacks/vue/coalesce-vue-vuetify/overview.md). See [c-display](/stacks/vue/coalesce-vue-vuetify/components/c-display.md) and See [c-display](/stacks/vue/coalesce-vue-vuetify/components/c-input.md) for details. - ### [ForeignKey] Normally, Coalesce figures out which properties are foreign keys, but if you don't use standard EF naming conventions then you'll need to annotate with `[ForeignKey]` to help out both EF and Coalesce. See the [Entity Framework Relationships](https://docs.microsoft.com/en-us/ef/core/modeling/relationships) documentation for more. @@ -61,4 +60,4 @@ Model properties that aren't mapped to the database should be marked with `[NotM ### [DefaultValue] -Properties with `[DefaultValue]` will recieve an initial value when a new model is created. Note: this is different from an initializer, the member will not be initialized with this value, see [remarks](https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.defaultvalueattribute?view=net-7.0#remarks). \ No newline at end of file +Properties with `[DefaultValue]` will receive the specified value when a new ViewModel is instantiated on the client. This enables scenarios like prefilling a required property with a suggested value. diff --git a/playground/Coalesce.Web.Vue2/src/metadata.g.ts b/playground/Coalesce.Web.Vue2/src/metadata.g.ts index 7961d2b4e..64e287a81 100644 --- a/playground/Coalesce.Web.Vue2/src/metadata.g.ts +++ b/playground/Coalesce.Web.Vue2/src/metadata.g.ts @@ -1217,6 +1217,7 @@ export const Person = domain.types.Person = { type: "enum", get typeDef() { return domain.enums.Genders }, role: "value", + defaultValue: 0, }, height: { name: "height", diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs index a6118f035..b02cb90f4 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs @@ -13,728 +13,743 @@ namespace IntelliTect.Coalesce.CodeGeneration.Vue.Generators { - public class TsMetadata : StringBuilderFileGenerator - { - public TsMetadata(GeneratorServices services) : base(services) - { - } - - public override Task BuildOutputAsync() - { - var b = new TypeScriptCodeBuilder(indentSize: 2); - - using (b.Block("import", " from 'coalesce-vue/lib/metadata'")) - { - b.Line("Domain, getEnumMeta, solidify, ModelType, ObjectType,"); - b.Line("PrimitiveProperty, ForeignKeyProperty, PrimaryKeyProperty,"); - b.Line("ModelCollectionNavigationProperty, ModelReferenceNavigationProperty,"); - b.Line("HiddenAreas, BehaviorFlags"); - } - b.Line(); - b.Line(); - - // Assigning each property as a member of domain ensures we don't break type contracts. - // Exporting each model individually offers easier usage in imports. - b.Line("const domain: Domain = { enums: {}, types: {}, services: {} }"); - - - - foreach (var model in Model.ClientEnums.OrderBy(e => e.ClientTypeName)) - { - WriteEnumMetadata(b, model); - } - - foreach (var model in Model.CrudApiBackedClasses.OrderBy(e => e.ClientTypeName)) - { - WriteApiBackedTypeMetadata(b, model); - } - - foreach (var model in Model.ExternalTypes.OrderBy(e => e.ClientTypeName)) - { - WriteExternalTypeMetadata(b, model); - } - - foreach (var model in Model.Services.OrderBy(e => e.ClientTypeName)) - { - WriteServiceMetadata(b, model); - } - - // Create an enhanced Domain definition for deep intellisense. - b.Line(); - using (b.Block("interface AppDomain extends Domain")) - { - using (b.Block("enums:")) - { - foreach (var model in Model.ClientEnums.OrderBy(e => e.Name)) - { - b.Line($"{model.Name}: typeof {model.Name}"); - } - } - using (b.Block("types:")) - { - foreach (var model in Model.ClientClasses.OrderBy(e => e.ClientTypeName)) - { - b.Line($"{model.ClientTypeName}: typeof {model.ClientTypeName}"); - } - } - using (b.Block("services:")) - { - foreach (var model in Model.Services.OrderBy(e => e.ClientTypeName)) - { - b.Line($"{model.ClientTypeName}: typeof {model.ClientTypeName}"); - } - } - } - - b.Line(); - b.Line("solidify(domain)"); - b.Line(); - // "as unknown" needed for some change in Typescript after around 3.8? - // This weirdly isn't needed in Coalese.Web.Vue, but is needed in basically - // all other consuming projects. - b.Line("export default domain as unknown as AppDomain"); - - - return Task.FromResult(b.ToString()); - } - - private void WriteCommonClassMetadata(TypeScriptCodeBuilder b, ClassViewModel model) - { - b.StringProp("name", model.ClientTypeName); - b.StringProp("displayName", model.DisplayName); - if (model.ListTextProperty != null) - { - // This might not be defined for external types, because sometimes it just doesn't make sense. We'll accommodate on the client. - b.Line($"get displayProp() {{ return this.props.{model.ListTextProperty.JsVariable} }}, "); - } - } - - private void WriteExternalTypeMetadata(TypeScriptCodeBuilder b, ClassViewModel model) - { - using (b.Block($"export const {model.ViewModelClassName} = domain.types.{model.ViewModelClassName} =")) - { - WriteCommonClassMetadata(b, model); - b.StringProp("type", "object"); - - WriteClassPropertiesMetadata(b, model); - } - } - - private void WriteApiBackedTypeMetadata(TypeScriptCodeBuilder b, ClassViewModel model) - { - using (b.Block($"export const {model.ViewModelClassName} = domain.types.{model.ViewModelClassName} =")) - { - WriteCommonClassMetadata(b, model); - b.StringProp("type", "model"); - b.StringProp("controllerRoute", model.ApiRouteControllerPart); - b.Line($"get keyProp() {{ return this.props.{model.PrimaryKey.JsVariable} }}, "); - - var securityInfo = model.SecurityInfo; - int flags = - (securityInfo.IsCreateAllowed() ? 1 << 0 : 0) | - (securityInfo.IsEditAllowed() ? 1 << 1 : 0) | - (securityInfo.IsDeleteAllowed() ? 1 << 2 : 0); - b.Prop("behaviorFlags", flags.ToString() + " as BehaviorFlags"); - - WriteClassPropertiesMetadata(b, model); - - WriteClassMethodMetadata(b, model); - - WriteDataSourcesMetadata(b, model); - } - } - - private void WriteEnumMetadata(TypeScriptCodeBuilder b, TypeViewModel model) - { - using (b.Block($"export const {model.ClientTypeName} = domain.enums.{model.ClientTypeName} =")) - { - b.StringProp("name", model.ClientTypeName); - b.StringProp("displayName", model.DisplayName); - b.StringProp("type", "enum"); - - string enumShape = string.Join("|", model.EnumValues.Select(ev => $"\"{ev.Name}\"")); - b.Line($"...getEnumMeta<{enumShape}>(["); - foreach (var value in model.EnumValues) - { - using (b.Block("", ",", leadingSpace: false)) - { - b.Prop("value", value.Value.ToString()); - b.StringProp("strValue", value.Name); - b.StringProp("displayName", value.DisplayName); - if (!string.IsNullOrWhiteSpace(value.Description)) - { - b.StringProp("description", value.Description); - } - } - } - b.Line("]),"); - } - } - - private void WriteServiceMetadata(TypeScriptCodeBuilder b, ClassViewModel model) - { - using (b.Block($"export const {model.ClientTypeName} = domain.services.{model.ClientTypeName} =")) - { - b.StringProp("name", model.ClientTypeName); - b.StringProp("displayName", model.DisplayName); - - b.StringProp("type", "service"); - b.StringProp("controllerRoute", model.ApiRouteControllerPart); - - WriteClassMethodMetadata(b, model); - } - } - - private void WriteClassPropertiesMetadata(TypeScriptCodeBuilder b, ClassViewModel model) - { - using (b.Block("props:", ',')) - { - foreach (var prop in model.ClientProperties.OrderBy(f => f.EditorOrder)) - { - WriteClassPropertyMetadata(b, model, prop); - } - } - } - - private static string GetClassMetadataRef(ClassViewModel obj = null) - { - // We need to qualify with "domain." instead of the exported const - // because in the case of a self-referential property, TypeScript can't handle recursive implicit type definitions. - - return $"(domain.types.{obj.ViewModelClassName} as {(obj.IsDbMappedType ? "ModelType" : "ObjectType")})"; - } - - private void WriteClassPropertyMetadata(TypeScriptCodeBuilder b, ClassViewModel model, PropertyViewModel prop) - { - using (b.Block($"{prop.JsVariable}:", ',')) - { - WriteValueCommonMetadata(b, prop); - - switch (prop.Role) - { - case PropertyRole.PrimaryKey: - // TS Type: "PrimaryKeyProperty" - b.StringProp("role", "primaryKey"); - break; - - case PropertyRole.ForeignKey: - // TS Type: "ForeignKeyProperty" - var principal = prop.ForeignKeyPrincipalType; - b.StringProp("role", "foreignKey"); - b.Line($"get principalKey() {{ return {GetClassMetadataRef(principal)}.props.{principal.PrimaryKey.JsVariable} as PrimaryKeyProperty }},"); - b.Line($"get principalType() {{ return {GetClassMetadataRef(principal)} }},"); - - if (prop.ReferenceNavigationProperty is { } navProp) - { - b.Line($"get navigationProp() {{ return {GetClassMetadataRef(model)}.props.{navProp.JsVariable} as ModelReferenceNavigationProperty }},"); - } - break; - - case PropertyRole.ReferenceNavigation: - // TS Type: "ModelReferenceNavigationProperty" - b.StringProp("role", "referenceNavigation"); - b.Line($"get foreignKey() {{ return {GetClassMetadataRef(model)}.props.{prop.ForeignKeyProperty.JsVariable} as ForeignKeyProperty }},"); - b.Line($"get principalKey() {{ return {GetClassMetadataRef(prop.Object)}.props.{prop.Object.PrimaryKey.JsVariable} as PrimaryKeyProperty }},"); - - if (prop.InverseProperty != null) - { - b.Line($"get inverseNavigation() {{ return {GetClassMetadataRef(prop.Object)}.props.{prop.InverseProperty.JsVariable} as ModelCollectionNavigationProperty }},"); - } - - break; - - case PropertyRole.CollectionNavigation: - // TS Type: "ModelCollectionNavigationProperty" - b.StringProp("role", "collectionNavigation"); - b.Line($"get foreignKey() {{ return {GetClassMetadataRef(prop.Object)}.props.{prop.ForeignKeyProperty.JsVariable} as ForeignKeyProperty }},"); - - if (prop.InverseProperty != null) - { - b.Line($"get inverseNavigation() {{ return {GetClassMetadataRef(prop.Object)}.props.{prop.InverseProperty.JsVariable} as ModelReferenceNavigationProperty }},"); - } - - if (prop.IsManytoManyCollection) - { - using (b.Block("manyToMany:", ",")) - { - var nearNavigation = prop.ManyToManyNearNavigationProperty; - var farNavigation = prop.ManyToManyFarNavigationProperty; - - b.StringProp("name", prop.ManyToManyCollectionName.ToCamelCase()); - b.StringProp("displayName", prop.ManyToManyCollectionName.ToProperCase()); - b.Line($"get typeDef() {{ return {GetClassMetadataRef(farNavigation.Object)} }},"); - b.Line($"get farForeignKey() {{ return {GetClassMetadataRef(prop.Object)}.props.{farNavigation.ForeignKeyProperty.JsVariable} as ForeignKeyProperty }},"); - b.Line($"get farNavigationProp() {{ return {GetClassMetadataRef(prop.Object)}.props.{farNavigation.JsVariable} as ModelReferenceNavigationProperty }},"); - b.Line($"get nearForeignKey() {{ return {GetClassMetadataRef(prop.Object)}.props.{nearNavigation.ForeignKeyProperty.JsVariable} as ForeignKeyProperty }},"); - b.Line($"get nearNavigationProp() {{ return {GetClassMetadataRef(prop.Object)}.props.{nearNavigation.JsVariable} as ModelReferenceNavigationProperty }},"); - } - } - - break; - - default: - b.StringProp("role", "value"); - break; - } - - int hiddenAreaFlags = (int)prop.HiddenAreas; - if (hiddenAreaFlags != 0) - { - b.Prop("hidden", hiddenAreaFlags.ToString() + " as HiddenAreas"); - } - - // We store the negative case instead of the positive - // because there are likely going to be more that are serializable than not. - if (!prop.IsClientSerializable) - { - b.Line("dontSerialize: true,"); - } - - if (prop.IsCreateOnly) - { - b.Line("createOnly: true,"); - } - - - if (prop.IsClientWritable && prop.IsClientSerializable && ( - prop.Type.TsTypeKind is - TypeDiscriminator.Number or - TypeDiscriminator.String or - TypeDiscriminator.Boolean or - TypeDiscriminator.Enum or - TypeDiscriminator.Date - )) - { - if (prop.DefaultValue is not null) - { - if (prop.Type.TsTypeKind is TypeDiscriminator.Date) - { - throw new NotSupportedException("Default date values currently not supported."); - } - if (prop.DefaultValue is string stringValue) - { - b.StringProp("defaultValue", stringValue); - } - else - { - // ToLower in case it happens to be boolean here. - b.Prop("defaultValue", prop.DefaultValue.ToString().ToLower()); - } - } - - List rules = GetValidationRules(prop, (prop.ReferenceNavigationProperty ?? prop).DisplayName); - - if (rules.Count > 0) - { - using (b.Block("rules:")) - { - foreach (var rule in rules) - { - b.Append(rule); - b.Line(","); - } - } - } - } - } - } - - private static List GetValidationRules(ValueViewModel prop, string propName) - { - // TODO: Handle 'ClientValidationAllowSave' by placing a field on the - // validator function that contains the value of this flag. - - var clientValidationError = prop.GetAttributeValue(a => a.ErrorMessage); - - var rules = new List(); - string Error(string message, string fallback) - { - if (string.IsNullOrWhiteSpace(message)) - { - message = fallback; - } - - return $"|| \"{message.EscapeStringLiteralForTypeScript()}\""; - } - - - // A simple falsey check will treat a numeric zero as "absent", so we explicitly check for - // null/undefined instead. - var requiredPredicate = prop.Type.IsString ? "(val != null && val !== '')" : "val != null"; - - var isRequired = prop.GetAttributeValue(a => a.IsRequired); - if (isRequired == true) - { - rules.Add($"required: val => {requiredPredicate} {Error(clientValidationError, $"{propName} is required.")}"); - } - else if (prop.IsRequired) - { - string message = null; - if (prop.GetValidationAttribute() is (true, string requiredMessage)) - { - message = requiredMessage; - if (prop.GetAttributeValue(a => a.AllowEmptyStrings) == true) - { - requiredPredicate = "val != null"; - } - } - if (string.IsNullOrWhiteSpace(message)) - { - message = $"{propName} is required."; - } - - rules.Add($"required: val => {requiredPredicate} || \"{message.EscapeStringLiteralForTypeScript()}\""); - } - - if (prop.Type.IsString) - { - void Min(object value, string error) => rules.Add($"minLength: val => !val || val.length >= {value} {Error(error, $"{propName} must be at least {value} characters.")}"); - void Max(object value, string error) => rules.Add($"maxLength: val => !val || val.length <= {value} {Error(error, $"{propName} may not be more than {value} characters.")}"); - - if (prop.GetValidationAttribute(x => x.MinimumLength) is (true, int min, string minMessage)) - Min(min, minMessage); - else if (prop.GetValidationAttribute(x => x.Length) is (true, int min2, string min2Message)) - Min(min2, min2Message); - else if (prop.GetAttributeValue(a => a.MinLength) is int minLength and not int.MaxValue) - Min(minLength, clientValidationError); - - if (prop.GetValidationAttribute(x => x.MaximumLength) is (true, int max, string maxMessage)) - Max(max, maxMessage); - else if (prop.GetValidationAttribute(x => x.Length) is (true, int max2, string max2Message)) - Max(max2, max2Message); - else if (prop.GetAttributeValue(a => a.MaxLength) is int maxLength and not int.MinValue) - Max(maxLength, clientValidationError); - - if (prop.GetValidationAttribute() is (true, string urlMessage)) - { - const string urlPattern = @"^((http(s)|ftp):\/\/.)"; - rules.Add($"url: val => !val || /{urlPattern}/.test(val) {Error(clientValidationError, $"{propName} must be a valid URL.")}"); - } - } - else if (prop.Type.IsNumber) - { - void Min(object value, string error) => rules.Add($"min: val => val == null || val >= {value} {Error(error, $"{propName} must be at least {value}.")}"); - void Max(object value, string error) => rules.Add($"max: val => val == null || val <= {value} {Error(error, $"{propName} may not be more than {value}.")}"); - - var range = prop.Range; - if (range != null) - { - var message = prop.GetAttributeValue(a => a.ErrorMessage); - Min(range.Item1, message); - Max(range.Item2, message); - } - else - { - if (prop.GetAttributeValue(a => a.MinValue) is double minValue and not double.MaxValue) - Min(minValue, clientValidationError); - - if (prop.GetAttributeValue(a => a.MaxValue) is double maxValue and not double.MinValue) - Max(maxValue, clientValidationError); - } - } - - - var pattern = prop.GetAttributeValue(a => a.Pattern); - if (pattern == null && prop.Type.IsGuid) - { - pattern = @"^\s*[{(]?[0-9A-Fa-f]{8}[-]?(?:[0-9A-Fa-f]{4}[-]?){3}[0-9A-Fa-f]{12}[)}]?\s*$"; - } - if (!string.IsNullOrEmpty(pattern)) - { - rules.Add($"pattern: val => !val || /{pattern}/.test(val) {Error(clientValidationError, $"{propName} does not match expected format.")}"); - } - - // https://emailregex.com/ - const string emailRegex = @"^(([^<>()\[\]\\.,;:\s@""]+(\.[^<> ()\[\]\\.,;:\s@""]+)*)|("".+ ""))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$"; - if (prop.GetAttributeValue(a => a.IsEmail) == true) - { - rules.Add($"email: val => !val || /{emailRegex}/.test(val.trim()) {Error(clientValidationError, $"{propName} must be a valid email address.")}"); - } - else if (prop.GetValidationAttribute() is (true, string message)) - { - // This is actually much more strict on the client than what EmailAddressAttribute actually validates on the server. - rules.Add($"email: val => !val || /{emailRegex}/.test(val.trim()) {Error(message, $"{propName} must be a valid email address.")}"); - } - - if (prop.GetAttributeValue(a => a.IsPhoneUs) == true) - { - const string phoneRegex = @"^(1-?)?(\([2-9]\d{2}\)|[2-9]\d{2})-?[2-9]\d{2}-?\d{4}$"; - rules.Add($"phone: val => !val || /{phoneRegex}/.test(val.replace(/\\s+/g, '')) {Error(clientValidationError, $"{propName} must be a valid US phone number.")}"); - } - else if (prop.GetValidationAttribute() is (true, string message)) - { - // This regex from https://github.com/dotnet/runtime/blob/ab0a4ff9fa8002fa17703a9f9571869b820846c3/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/PhoneAttribute.cs#LL15C9-L15C9 - // and matches the behavior of the server validation - const string phoneRegex = @"^(\+\s?)?((? !val || /{phoneRegex}/.test(val.replace(/\\s+/g, '')) {Error(message, $"{propName} must be a valid phone number.")}"); - } - - return rules; - } - - /// - /// Write the metadata for all methods of a class - /// - private void WriteClassMethodMetadata(TypeScriptCodeBuilder b, ClassViewModel model) - { - using (b.Block("methods:", ',')) - { - foreach (var method in model.ClientMethods) - { - WriteClassMethodMetadata(b, model, method); - } - } - } - - /// - /// Write the metadata for an entire method - /// - private void WriteClassMethodMetadata(TypeScriptCodeBuilder b, ClassViewModel model, MethodViewModel method) - { - using (b.Block($"{method.JsVariable}:", ',')) - { - - b.StringProp("name", method.JsVariable); - b.StringProp("displayName", method.DisplayName); - b.StringProp("transportType", method.TransportType.ToString().Replace("Result", "").ToLower()); - b.StringProp("httpMethod", method.ApiActionHttpMethod.ToString().ToUpperInvariant()); - - if (method.IsStatic) - { - b.Prop("isStatic", "true"); - } - - if (method.GetAttributeValue(e => e.AutoClear) == true) - { - b.Prop("autoClear", "true"); - } - - using (b.Block("params:", ',')) - { - foreach (var param in method.ApiParameters) - { - WriteMethodParameterMetadata(b, method, param); - } - } - - using (b.Block("return:", ',')) - { - WriteValueCommonMetadata(b, method.Return); - b.StringProp("role", "value"); - } - } - } - - /// - /// Write the metadata for a specific parameter to a specific method - /// - private void WriteMethodParameterMetadata(TypeScriptCodeBuilder b, MethodViewModel method, ParameterViewModel parameter) - { - using (b.Block($"{parameter.JsVariable}:", ',')) - { - WriteValueCommonMetadata(b, parameter); - b.StringProp("role", "value"); - if (parameter.ParentSourceProp != null) - { - b.Line($"get source() {{ return {GetClassMetadataRef(method.Parent)}.props.{parameter.ParentSourceProp.JsVariable} }},"); - } - - List rules = GetValidationRules(parameter, parameter.DisplayName); - - if (rules.Count > 0) - { - using (b.Block("rules:")) - { - foreach (var rule in rules) - { - b.Append(rule); - b.Line(","); - } - } - } - } - } - - /// - /// Write the metadata for all data sources of a class - /// - private void WriteDataSourcesMetadata(TypeScriptCodeBuilder b, ClassViewModel model) - { - using (b.Block("dataSources:", ',')) - { - var dataSources = model.ClientDataSources(this.Model); - foreach (var source in dataSources) - { - WriteDataSourceMetadata(b, model, source); - } - - // Not sure we need to explicitly declare the default source. - // We can just use the absense of a data source to represent the default. - /* - var defaultSource = dataSources.SingleOrDefault(s => s.IsDefaultDataSource); - if (defaultSource != null) - { - var name = defaultSource.ClientTypeName.ToCamelCase(); - b.Line($"get default() {{ return this.{name} }},"); - } - else - { - using (b.Block($"default:", ',')) - { - b.StringProp("type", "dataSource"); - b.StringProp("name", "default"); - b.StringProp("displayName", "Default"); - b.Line("params: {}"); - } - } - */ - } - } - - /// - /// Write the metadata for all data sources of a class - /// - private void WriteDataSourceMetadata(TypeScriptCodeBuilder b, ClassViewModel model, ClassViewModel source) - { - // TODO: Should we be camel-casing the names of data sources in the metadata? - // TODO: OR, should we be not camel casing the members we place on the domain[key: string] objects? - using (b.Block($"{source.ClientTypeName.ToCamelCase()}:", ',')) - { - b.StringProp("type", "dataSource"); - - WriteCommonClassMetadata(b, source); - - if (source.IsDefaultDataSource) - { - b.Line("isDefault: true,"); - } - - using (b.Block("props:", ',')) - { - foreach (var prop in source.DataSourceParameters) - { - WriteClassPropertyMetadata(b, model, prop); - } - } - } - } - - /// - /// Write metadata common to all value representations, like properties and method parameters. - /// - private void WriteValueCommonMetadata(TypeScriptCodeBuilder b, ValueViewModel value) - { - b.StringProp("name", value.JsVariable); - b.StringProp("displayName", value.DisplayName); - - if (!string.IsNullOrWhiteSpace(value.Description)) - { - b.StringProp("description", value.Description); - } - - WriteTypeCommonMetadata(b, value.Type, value); - } - - /// - /// Write metadata common to all type representations, - /// like properties, method parameters, method returns, etc. - /// - private void WriteTypeCommonMetadata(TypeScriptCodeBuilder b, TypeViewModel type, ValueViewModel definingMember) - { - var kind = type.TsTypeKind; - var subtype = definingMember.GetAttributeValue(a => a.DataType); - - switch (kind) - { - case TypeDiscriminator.Unknown: - if (type.FullyQualifiedName != "System.Object") - { - // System.Object technically _is_ supported via "unknown", but any derived type - // that isn't otherwise explicitly supported by Coalesce should have this message. - b.Line("// Type not supported natively by Coalesce - falling back to unknown."); - } - b.StringProp("type", "unknown"); - break; - - default: - b.StringProp("type", kind.ToString().ToLowerInvariant()); - break; - } - - switch (kind) - { - case TypeDiscriminator.Enum: - b.Line($"get typeDef() {{ return domain.enums.{type.ClientTypeName} }},"); - break; - - case TypeDiscriminator.Model: - case TypeDiscriminator.Object: - b.Line($"get typeDef() {{ return {GetClassMetadataRef(type.ClassViewModel)} }},"); - break; - - case TypeDiscriminator.Collection: - // For collections, write the references to the underlying type. - if (type.PureType.TsTypeKind == TypeDiscriminator.Collection) - { - throw new InvalidOperationException("Collections of collections aren't supported by Coalesce as exposed types"); - } - - using (b.Block($"itemType:", ',')) - { - b.StringProp("name", "$collectionItem"); - b.StringProp("displayName", ""); - b.StringProp("role", "value"); - WriteTypeCommonMetadata(b, type.PureType, definingMember); - } - break; - - case TypeDiscriminator.Date: - var dateType = definingMember.DateType; - - b.StringProp("dateKind", dateType switch - { - DateTypes.DateOnly => "date", - DateTypes.TimeOnly => "time", - _ => "datetime" - }); - - if (!type.IsDateTimeOffset) - { - b.Prop("noOffset", "true"); - } - break; - - case TypeDiscriminator.Binary: - if (definingMember is PropertyViewModel) - { - b.Prop("base64", "true"); - } - break; - - case TypeDiscriminator.String: - b.StringProp("subtype", subtype switch - { - // HTML s: - DataType.Password => "password", - DataType.Url => "url", - DataType.EmailAddress => "email", - DataType.PhoneNumber => "tel", - - // Others: - DataType.MultilineText => "multiline", - DataType.ImageUrl => "url-image", - _ => definingMember.GetAttributeValue(a => a.CustomDataType)?.ToLowerInvariant() switch - { - "color" => "color", - _ => - definingMember.HasAttribute() ? "url" : - definingMember.HasAttribute() ? "email" : - definingMember.HasAttribute() ? "tel" : - null - } - }, omitIfNull: true); - break; - } - } - } + public class TsMetadata : StringBuilderFileGenerator + { + public TsMetadata(GeneratorServices services) : base(services) + { + } + + public override Task BuildOutputAsync() + { + var b = new TypeScriptCodeBuilder(indentSize: 2); + + using (b.Block("import", " from 'coalesce-vue/lib/metadata'")) + { + b.Line("Domain, getEnumMeta, solidify, ModelType, ObjectType,"); + b.Line("PrimitiveProperty, ForeignKeyProperty, PrimaryKeyProperty,"); + b.Line("ModelCollectionNavigationProperty, ModelReferenceNavigationProperty,"); + b.Line("HiddenAreas, BehaviorFlags"); + } + b.Line(); + b.Line(); + + // Assigning each property as a member of domain ensures we don't break type contracts. + // Exporting each model individually offers easier usage in imports. + b.Line("const domain: Domain = { enums: {}, types: {}, services: {} }"); + + + + foreach (var model in Model.ClientEnums.OrderBy(e => e.ClientTypeName)) + { + WriteEnumMetadata(b, model); + } + + foreach (var model in Model.CrudApiBackedClasses.OrderBy(e => e.ClientTypeName)) + { + WriteApiBackedTypeMetadata(b, model); + } + + foreach (var model in Model.ExternalTypes.OrderBy(e => e.ClientTypeName)) + { + WriteExternalTypeMetadata(b, model); + } + + foreach (var model in Model.Services.OrderBy(e => e.ClientTypeName)) + { + WriteServiceMetadata(b, model); + } + + // Create an enhanced Domain definition for deep intellisense. + b.Line(); + using (b.Block("interface AppDomain extends Domain")) + { + using (b.Block("enums:")) + { + foreach (var model in Model.ClientEnums.OrderBy(e => e.Name)) + { + b.Line($"{model.Name}: typeof {model.Name}"); + } + } + using (b.Block("types:")) + { + foreach (var model in Model.ClientClasses.OrderBy(e => e.ClientTypeName)) + { + b.Line($"{model.ClientTypeName}: typeof {model.ClientTypeName}"); + } + } + using (b.Block("services:")) + { + foreach (var model in Model.Services.OrderBy(e => e.ClientTypeName)) + { + b.Line($"{model.ClientTypeName}: typeof {model.ClientTypeName}"); + } + } + } + + b.Line(); + b.Line("solidify(domain)"); + b.Line(); + // "as unknown" needed for some change in Typescript after around 3.8? + // This weirdly isn't needed in Coalese.Web.Vue, but is needed in basically + // all other consuming projects. + b.Line("export default domain as unknown as AppDomain"); + + + return Task.FromResult(b.ToString()); + } + + private void WriteCommonClassMetadata(TypeScriptCodeBuilder b, ClassViewModel model) + { + b.StringProp("name", model.ClientTypeName); + b.StringProp("displayName", model.DisplayName); + if (model.ListTextProperty != null) + { + // This might not be defined for external types, because sometimes it just doesn't make sense. We'll accommodate on the client. + b.Line($"get displayProp() {{ return this.props.{model.ListTextProperty.JsVariable} }}, "); + } + } + + private void WriteExternalTypeMetadata(TypeScriptCodeBuilder b, ClassViewModel model) + { + using (b.Block($"export const {model.ViewModelClassName} = domain.types.{model.ViewModelClassName} =")) + { + WriteCommonClassMetadata(b, model); + b.StringProp("type", "object"); + + WriteClassPropertiesMetadata(b, model); + } + } + + private void WriteApiBackedTypeMetadata(TypeScriptCodeBuilder b, ClassViewModel model) + { + using (b.Block($"export const {model.ViewModelClassName} = domain.types.{model.ViewModelClassName} =")) + { + WriteCommonClassMetadata(b, model); + b.StringProp("type", "model"); + b.StringProp("controllerRoute", model.ApiRouteControllerPart); + b.Line($"get keyProp() {{ return this.props.{model.PrimaryKey.JsVariable} }}, "); + + var securityInfo = model.SecurityInfo; + int flags = + (securityInfo.IsCreateAllowed() ? 1 << 0 : 0) | + (securityInfo.IsEditAllowed() ? 1 << 1 : 0) | + (securityInfo.IsDeleteAllowed() ? 1 << 2 : 0); + b.Prop("behaviorFlags", flags.ToString() + " as BehaviorFlags"); + + WriteClassPropertiesMetadata(b, model); + + WriteClassMethodMetadata(b, model); + + WriteDataSourcesMetadata(b, model); + } + } + + private void WriteEnumMetadata(TypeScriptCodeBuilder b, TypeViewModel model) + { + using (b.Block($"export const {model.ClientTypeName} = domain.enums.{model.ClientTypeName} =")) + { + b.StringProp("name", model.ClientTypeName); + b.StringProp("displayName", model.DisplayName); + b.StringProp("type", "enum"); + + string enumShape = string.Join("|", model.EnumValues.Select(ev => $"\"{ev.Name}\"")); + b.Line($"...getEnumMeta<{enumShape}>(["); + foreach (var value in model.EnumValues) + { + using (b.Block("", ",", leadingSpace: false)) + { + b.Prop("value", value.Value.ToString()); + b.StringProp("strValue", value.Name); + b.StringProp("displayName", value.DisplayName); + if (!string.IsNullOrWhiteSpace(value.Description)) + { + b.StringProp("description", value.Description); + } + } + } + b.Line("]),"); + } + } + + private void WriteServiceMetadata(TypeScriptCodeBuilder b, ClassViewModel model) + { + using (b.Block($"export const {model.ClientTypeName} = domain.services.{model.ClientTypeName} =")) + { + b.StringProp("name", model.ClientTypeName); + b.StringProp("displayName", model.DisplayName); + + b.StringProp("type", "service"); + b.StringProp("controllerRoute", model.ApiRouteControllerPart); + + WriteClassMethodMetadata(b, model); + } + } + + private void WriteClassPropertiesMetadata(TypeScriptCodeBuilder b, ClassViewModel model) + { + using (b.Block("props:", ',')) + { + foreach (var prop in model.ClientProperties.OrderBy(f => f.EditorOrder)) + { + WriteClassPropertyMetadata(b, model, prop); + } + } + } + + private static string GetClassMetadataRef(ClassViewModel obj = null) + { + // We need to qualify with "domain." instead of the exported const + // because in the case of a self-referential property, TypeScript can't handle recursive implicit type definitions. + + return $"(domain.types.{obj.ViewModelClassName} as {(obj.IsDbMappedType ? "ModelType" : "ObjectType")})"; + } + + private void WriteClassPropertyMetadata(TypeScriptCodeBuilder b, ClassViewModel model, PropertyViewModel prop) + { + using (b.Block($"{prop.JsVariable}:", ',')) + { + WriteValueCommonMetadata(b, prop); + + switch (prop.Role) + { + case PropertyRole.PrimaryKey: + // TS Type: "PrimaryKeyProperty" + b.StringProp("role", "primaryKey"); + break; + + case PropertyRole.ForeignKey: + // TS Type: "ForeignKeyProperty" + var principal = prop.ForeignKeyPrincipalType; + b.StringProp("role", "foreignKey"); + b.Line($"get principalKey() {{ return {GetClassMetadataRef(principal)}.props.{principal.PrimaryKey.JsVariable} as PrimaryKeyProperty }},"); + b.Line($"get principalType() {{ return {GetClassMetadataRef(principal)} }},"); + + if (prop.ReferenceNavigationProperty is { } navProp) + { + b.Line($"get navigationProp() {{ return {GetClassMetadataRef(model)}.props.{navProp.JsVariable} as ModelReferenceNavigationProperty }},"); + } + break; + + case PropertyRole.ReferenceNavigation: + // TS Type: "ModelReferenceNavigationProperty" + b.StringProp("role", "referenceNavigation"); + b.Line($"get foreignKey() {{ return {GetClassMetadataRef(model)}.props.{prop.ForeignKeyProperty.JsVariable} as ForeignKeyProperty }},"); + b.Line($"get principalKey() {{ return {GetClassMetadataRef(prop.Object)}.props.{prop.Object.PrimaryKey.JsVariable} as PrimaryKeyProperty }},"); + + if (prop.InverseProperty != null) + { + b.Line($"get inverseNavigation() {{ return {GetClassMetadataRef(prop.Object)}.props.{prop.InverseProperty.JsVariable} as ModelCollectionNavigationProperty }},"); + } + + break; + + case PropertyRole.CollectionNavigation: + // TS Type: "ModelCollectionNavigationProperty" + b.StringProp("role", "collectionNavigation"); + b.Line($"get foreignKey() {{ return {GetClassMetadataRef(prop.Object)}.props.{prop.ForeignKeyProperty.JsVariable} as ForeignKeyProperty }},"); + + if (prop.InverseProperty != null) + { + b.Line($"get inverseNavigation() {{ return {GetClassMetadataRef(prop.Object)}.props.{prop.InverseProperty.JsVariable} as ModelReferenceNavigationProperty }},"); + } + + if (prop.IsManytoManyCollection) + { + using (b.Block("manyToMany:", ",")) + { + var nearNavigation = prop.ManyToManyNearNavigationProperty; + var farNavigation = prop.ManyToManyFarNavigationProperty; + + b.StringProp("name", prop.ManyToManyCollectionName.ToCamelCase()); + b.StringProp("displayName", prop.ManyToManyCollectionName.ToProperCase()); + b.Line($"get typeDef() {{ return {GetClassMetadataRef(farNavigation.Object)} }},"); + b.Line($"get farForeignKey() {{ return {GetClassMetadataRef(prop.Object)}.props.{farNavigation.ForeignKeyProperty.JsVariable} as ForeignKeyProperty }},"); + b.Line($"get farNavigationProp() {{ return {GetClassMetadataRef(prop.Object)}.props.{farNavigation.JsVariable} as ModelReferenceNavigationProperty }},"); + b.Line($"get nearForeignKey() {{ return {GetClassMetadataRef(prop.Object)}.props.{nearNavigation.ForeignKeyProperty.JsVariable} as ForeignKeyProperty }},"); + b.Line($"get nearNavigationProp() {{ return {GetClassMetadataRef(prop.Object)}.props.{nearNavigation.JsVariable} as ModelReferenceNavigationProperty }},"); + } + } + + break; + + default: + b.StringProp("role", "value"); + break; + } + + int hiddenAreaFlags = (int)prop.HiddenAreas; + if (hiddenAreaFlags != 0) + { + b.Prop("hidden", hiddenAreaFlags.ToString() + " as HiddenAreas"); + } + + // We store the negative case instead of the positive + // because there are likely going to be more that are serializable than not. + if (!prop.IsClientSerializable) + { + b.Line("dontSerialize: true,"); + } + + if (prop.IsCreateOnly) + { + b.Line("createOnly: true,"); + } + + + if (prop.IsClientWritable && prop.IsClientSerializable && ( + prop.Type.TsTypeKind is + TypeDiscriminator.Number or + TypeDiscriminator.String or + TypeDiscriminator.Boolean or + TypeDiscriminator.Enum or + TypeDiscriminator.Date + )) + { + var defaultValue = prop.DefaultValue; + if (defaultValue is not null) + { + if ( + prop.Type.TsTypeKind is TypeDiscriminator.String && + defaultValue is string stringValue + ) + { + b.StringProp("defaultValue", stringValue); + } + else if ( + prop.Type.TsTypeKind is TypeDiscriminator.Boolean && + defaultValue is true or false + ) + { + b.Prop("defaultValue", defaultValue.ToString().ToLowerInvariant()); + } + else if ( + prop.Type.TsTypeKind is TypeDiscriminator.Number or TypeDiscriminator.Enum && + double.TryParse(defaultValue.ToString(), out _) + ) + { + b.Prop("defaultValue", defaultValue.ToString()); + } + else + { + throw new InvalidOperationException( + $"Default value {defaultValue} does not match property type {prop.Type}, " + + $"or type does not support default values."); + } + } + + List rules = GetValidationRules(prop, (prop.ReferenceNavigationProperty ?? prop).DisplayName); + + if (rules.Count > 0) + { + using (b.Block("rules:")) + { + foreach (var rule in rules) + { + b.Append(rule); + b.Line(","); + } + } + } + } + } + } + + private static List GetValidationRules(ValueViewModel prop, string propName) + { + // TODO: Handle 'ClientValidationAllowSave' by placing a field on the + // validator function that contains the value of this flag. + + var clientValidationError = prop.GetAttributeValue(a => a.ErrorMessage); + + var rules = new List(); + string Error(string message, string fallback) + { + if (string.IsNullOrWhiteSpace(message)) + { + message = fallback; + } + + return $"|| \"{message.EscapeStringLiteralForTypeScript()}\""; + } + + + // A simple falsey check will treat a numeric zero as "absent", so we explicitly check for + // null/undefined instead. + var requiredPredicate = prop.Type.IsString ? "(val != null && val !== '')" : "val != null"; + + var isRequired = prop.GetAttributeValue(a => a.IsRequired); + if (isRequired == true) + { + rules.Add($"required: val => {requiredPredicate} {Error(clientValidationError, $"{propName} is required.")}"); + } + else if (prop.IsRequired) + { + string message = null; + if (prop.GetValidationAttribute() is (true, string requiredMessage)) + { + message = requiredMessage; + if (prop.GetAttributeValue(a => a.AllowEmptyStrings) == true) + { + requiredPredicate = "val != null"; + } + } + if (string.IsNullOrWhiteSpace(message)) + { + message = $"{propName} is required."; + } + + rules.Add($"required: val => {requiredPredicate} || \"{message.EscapeStringLiteralForTypeScript()}\""); + } + + if (prop.Type.IsString) + { + void Min(object value, string error) => rules.Add($"minLength: val => !val || val.length >= {value} {Error(error, $"{propName} must be at least {value} characters.")}"); + void Max(object value, string error) => rules.Add($"maxLength: val => !val || val.length <= {value} {Error(error, $"{propName} may not be more than {value} characters.")}"); + + if (prop.GetValidationAttribute(x => x.MinimumLength) is (true, int min, string minMessage)) + Min(min, minMessage); + else if (prop.GetValidationAttribute(x => x.Length) is (true, int min2, string min2Message)) + Min(min2, min2Message); + else if (prop.GetAttributeValue(a => a.MinLength) is int minLength and not int.MaxValue) + Min(minLength, clientValidationError); + + if (prop.GetValidationAttribute(x => x.MaximumLength) is (true, int max, string maxMessage)) + Max(max, maxMessage); + else if (prop.GetValidationAttribute(x => x.Length) is (true, int max2, string max2Message)) + Max(max2, max2Message); + else if (prop.GetAttributeValue(a => a.MaxLength) is int maxLength and not int.MinValue) + Max(maxLength, clientValidationError); + + if (prop.GetValidationAttribute() is (true, string urlMessage)) + { + const string urlPattern = @"^((http(s)|ftp):\/\/.)"; + rules.Add($"url: val => !val || /{urlPattern}/.test(val) {Error(clientValidationError, $"{propName} must be a valid URL.")}"); + } + } + else if (prop.Type.IsNumber) + { + void Min(object value, string error) => rules.Add($"min: val => val == null || val >= {value} {Error(error, $"{propName} must be at least {value}.")}"); + void Max(object value, string error) => rules.Add($"max: val => val == null || val <= {value} {Error(error, $"{propName} may not be more than {value}.")}"); + + var range = prop.Range; + if (range != null) + { + var message = prop.GetAttributeValue(a => a.ErrorMessage); + Min(range.Item1, message); + Max(range.Item2, message); + } + else + { + if (prop.GetAttributeValue(a => a.MinValue) is double minValue and not double.MaxValue) + Min(minValue, clientValidationError); + + if (prop.GetAttributeValue(a => a.MaxValue) is double maxValue and not double.MinValue) + Max(maxValue, clientValidationError); + } + } + + + var pattern = prop.GetAttributeValue(a => a.Pattern); + if (pattern == null && prop.Type.IsGuid) + { + pattern = @"^\s*[{(]?[0-9A-Fa-f]{8}[-]?(?:[0-9A-Fa-f]{4}[-]?){3}[0-9A-Fa-f]{12}[)}]?\s*$"; + } + if (!string.IsNullOrEmpty(pattern)) + { + rules.Add($"pattern: val => !val || /{pattern}/.test(val) {Error(clientValidationError, $"{propName} does not match expected format.")}"); + } + + // https://emailregex.com/ + const string emailRegex = @"^(([^<>()\[\]\\.,;:\s@""]+(\.[^<> ()\[\]\\.,;:\s@""]+)*)|("".+ ""))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$"; + if (prop.GetAttributeValue(a => a.IsEmail) == true) + { + rules.Add($"email: val => !val || /{emailRegex}/.test(val.trim()) {Error(clientValidationError, $"{propName} must be a valid email address.")}"); + } + else if (prop.GetValidationAttribute() is (true, string message)) + { + // This is actually much more strict on the client than what EmailAddressAttribute actually validates on the server. + rules.Add($"email: val => !val || /{emailRegex}/.test(val.trim()) {Error(message, $"{propName} must be a valid email address.")}"); + } + + if (prop.GetAttributeValue(a => a.IsPhoneUs) == true) + { + const string phoneRegex = @"^(1-?)?(\([2-9]\d{2}\)|[2-9]\d{2})-?[2-9]\d{2}-?\d{4}$"; + rules.Add($"phone: val => !val || /{phoneRegex}/.test(val.replace(/\\s+/g, '')) {Error(clientValidationError, $"{propName} must be a valid US phone number.")}"); + } + else if (prop.GetValidationAttribute() is (true, string message)) + { + // This regex from https://github.com/dotnet/runtime/blob/ab0a4ff9fa8002fa17703a9f9571869b820846c3/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/PhoneAttribute.cs#LL15C9-L15C9 + // and matches the behavior of the server validation + const string phoneRegex = @"^(\+\s?)?((? !val || /{phoneRegex}/.test(val.replace(/\\s+/g, '')) {Error(message, $"{propName} must be a valid phone number.")}"); + } + + return rules; + } + + /// + /// Write the metadata for all methods of a class + /// + private void WriteClassMethodMetadata(TypeScriptCodeBuilder b, ClassViewModel model) + { + using (b.Block("methods:", ',')) + { + foreach (var method in model.ClientMethods) + { + WriteClassMethodMetadata(b, model, method); + } + } + } + + /// + /// Write the metadata for an entire method + /// + private void WriteClassMethodMetadata(TypeScriptCodeBuilder b, ClassViewModel model, MethodViewModel method) + { + using (b.Block($"{method.JsVariable}:", ',')) + { + + b.StringProp("name", method.JsVariable); + b.StringProp("displayName", method.DisplayName); + b.StringProp("transportType", method.TransportType.ToString().Replace("Result", "").ToLower()); + b.StringProp("httpMethod", method.ApiActionHttpMethod.ToString().ToUpperInvariant()); + + if (method.IsStatic) + { + b.Prop("isStatic", "true"); + } + + if (method.GetAttributeValue(e => e.AutoClear) == true) + { + b.Prop("autoClear", "true"); + } + + using (b.Block("params:", ',')) + { + foreach (var param in method.ApiParameters) + { + WriteMethodParameterMetadata(b, method, param); + } + } + + using (b.Block("return:", ',')) + { + WriteValueCommonMetadata(b, method.Return); + b.StringProp("role", "value"); + } + } + } + + /// + /// Write the metadata for a specific parameter to a specific method + /// + private void WriteMethodParameterMetadata(TypeScriptCodeBuilder b, MethodViewModel method, ParameterViewModel parameter) + { + using (b.Block($"{parameter.JsVariable}:", ',')) + { + WriteValueCommonMetadata(b, parameter); + b.StringProp("role", "value"); + if (parameter.ParentSourceProp != null) + { + b.Line($"get source() {{ return {GetClassMetadataRef(method.Parent)}.props.{parameter.ParentSourceProp.JsVariable} }},"); + } + + List rules = GetValidationRules(parameter, parameter.DisplayName); + + if (rules.Count > 0) + { + using (b.Block("rules:")) + { + foreach (var rule in rules) + { + b.Append(rule); + b.Line(","); + } + } + } + } + } + + /// + /// Write the metadata for all data sources of a class + /// + private void WriteDataSourcesMetadata(TypeScriptCodeBuilder b, ClassViewModel model) + { + using (b.Block("dataSources:", ',')) + { + var dataSources = model.ClientDataSources(this.Model); + foreach (var source in dataSources) + { + WriteDataSourceMetadata(b, model, source); + } + + // Not sure we need to explicitly declare the default source. + // We can just use the absense of a data source to represent the default. + /* + var defaultSource = dataSources.SingleOrDefault(s => s.IsDefaultDataSource); + if (defaultSource != null) + { + var name = defaultSource.ClientTypeName.ToCamelCase(); + b.Line($"get default() {{ return this.{name} }},"); + } + else + { + using (b.Block($"default:", ',')) + { + b.StringProp("type", "dataSource"); + b.StringProp("name", "default"); + b.StringProp("displayName", "Default"); + b.Line("params: {}"); + } + } + */ + } + } + + /// + /// Write the metadata for all data sources of a class + /// + private void WriteDataSourceMetadata(TypeScriptCodeBuilder b, ClassViewModel model, ClassViewModel source) + { + // TODO: Should we be camel-casing the names of data sources in the metadata? + // TODO: OR, should we be not camel casing the members we place on the domain[key: string] objects? + using (b.Block($"{source.ClientTypeName.ToCamelCase()}:", ',')) + { + b.StringProp("type", "dataSource"); + + WriteCommonClassMetadata(b, source); + + if (source.IsDefaultDataSource) + { + b.Line("isDefault: true,"); + } + + using (b.Block("props:", ',')) + { + foreach (var prop in source.DataSourceParameters) + { + WriteClassPropertyMetadata(b, model, prop); + } + } + } + } + + /// + /// Write metadata common to all value representations, like properties and method parameters. + /// + private void WriteValueCommonMetadata(TypeScriptCodeBuilder b, ValueViewModel value) + { + b.StringProp("name", value.JsVariable); + b.StringProp("displayName", value.DisplayName); + + if (!string.IsNullOrWhiteSpace(value.Description)) + { + b.StringProp("description", value.Description); + } + + WriteTypeCommonMetadata(b, value.Type, value); + } + + /// + /// Write metadata common to all type representations, + /// like properties, method parameters, method returns, etc. + /// + private void WriteTypeCommonMetadata(TypeScriptCodeBuilder b, TypeViewModel type, ValueViewModel definingMember) + { + var kind = type.TsTypeKind; + var subtype = definingMember.GetAttributeValue(a => a.DataType); + + switch (kind) + { + case TypeDiscriminator.Unknown: + if (type.FullyQualifiedName != "System.Object") + { + // System.Object technically _is_ supported via "unknown", but any derived type + // that isn't otherwise explicitly supported by Coalesce should have this message. + b.Line("// Type not supported natively by Coalesce - falling back to unknown."); + } + b.StringProp("type", "unknown"); + break; + + default: + b.StringProp("type", kind.ToString().ToLowerInvariant()); + break; + } + + switch (kind) + { + case TypeDiscriminator.Enum: + b.Line($"get typeDef() {{ return domain.enums.{type.ClientTypeName} }},"); + break; + + case TypeDiscriminator.Model: + case TypeDiscriminator.Object: + b.Line($"get typeDef() {{ return {GetClassMetadataRef(type.ClassViewModel)} }},"); + break; + + case TypeDiscriminator.Collection: + // For collections, write the references to the underlying type. + if (type.PureType.TsTypeKind == TypeDiscriminator.Collection) + { + throw new InvalidOperationException("Collections of collections aren't supported by Coalesce as exposed types"); + } + + using (b.Block($"itemType:", ',')) + { + b.StringProp("name", "$collectionItem"); + b.StringProp("displayName", ""); + b.StringProp("role", "value"); + WriteTypeCommonMetadata(b, type.PureType, definingMember); + } + break; + + case TypeDiscriminator.Date: + var dateType = definingMember.DateType; + + b.StringProp("dateKind", dateType switch + { + DateTypes.DateOnly => "date", + DateTypes.TimeOnly => "time", + _ => "datetime" + }); + + if (!type.IsDateTimeOffset) + { + b.Prop("noOffset", "true"); + } + break; + + case TypeDiscriminator.Binary: + if (definingMember is PropertyViewModel) + { + b.Prop("base64", "true"); + } + break; + + case TypeDiscriminator.String: + b.StringProp("subtype", subtype switch + { + // HTML s: + DataType.Password => "password", + DataType.Url => "url", + DataType.EmailAddress => "email", + DataType.PhoneNumber => "tel", + + // Others: + DataType.MultilineText => "multiline", + DataType.ImageUrl => "url-image", + _ => definingMember.GetAttributeValue(a => a.CustomDataType)?.ToLowerInvariant() switch + { + "color" => "color", + _ => + definingMember.HasAttribute() ? "url" : + definingMember.HasAttribute() ? "email" : + definingMember.HasAttribute() ? "tel" : + null + } + }, omitIfNull: true); + break; + } + } + } } diff --git a/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/ComplexModel.cs b/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/ComplexModel.cs index bbd91a83b..ce5cc8802 100644 --- a/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/ComplexModel.cs +++ b/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/ComplexModel.cs @@ -2,6 +2,7 @@ using IntelliTect.Coalesce.Models; using System; using System.Collections.Generic; +using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Linq; @@ -99,6 +100,18 @@ public class ComplexModel public string String { get; set; } + [DefaultValue("Inigo")] + public string StringWithDefault { get; set; } + + [DefaultValue(42)] + public int IntWithDefault { get; set; } + + [DefaultValue(Math.PI)] + public double DoubleWithDefault { get; set; } + + [DefaultValue(EnumPkId.Value10)] + public EnumPkId EnumWithDefault{ get; set; } + [DataType("Color")] public string Color { get; set; } diff --git a/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs b/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs index 9a81d5136..559e5e4da 100644 --- a/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs +++ b/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs @@ -14,7 +14,6 @@ using System.Linq.Expressions; using IntelliTect.Coalesce.TypeDefinition.Enums; using System.Diagnostics.CodeAnalysis; -using Microsoft.EntityFrameworkCore; namespace IntelliTect.Coalesce.TypeDefinition { @@ -246,11 +245,12 @@ propName is null public bool IsDateOnly => DateType == DateTypeAttribute.DateTypes.DateOnly; /// - /// Returns a default value if provided. + /// Returns the default value specified by , if present. /// public object? DefaultValue => this.GetAttributeValue(nameof(DefaultValueAttribute.Value)); - /// /// If true, there is an API controller that is serving this type of data. + /// + /// If true, there is an API controller that is serving this type of data. /// public bool HasValidValues => IsManytoManyCollection || ((Object?.IsDbMappedType ?? false) && IsPOCO); diff --git a/src/coalesce-vue/src/metadata.ts b/src/coalesce-vue/src/metadata.ts index 7f9a66e5b..8176a9829 100644 --- a/src/coalesce-vue/src/metadata.ts +++ b/src/coalesce-vue/src/metadata.ts @@ -274,7 +274,6 @@ export interface ValueMeta extends Metadata { readonly role: ValueRole; readonly type: TType; readonly description?: string; - readonly defaultValue?: string | number | boolean; } /** @@ -303,17 +302,20 @@ export interface StringValue extends ValueMeta<"string"> { | "url-image"; readonly rules?: Rules; + readonly defaultValue?: string; } /** Represents the usage of a number */ export interface NumberValue extends ValueMeta<"number"> { readonly role: "value" | "foreignKey" | "primaryKey"; readonly rules?: Rules; + readonly defaultValue?: number; } /** Represents the usage of a boolean */ export interface BooleanValue extends ValueMeta<"boolean"> { readonly rules?: Rules; + readonly defaultValue?: boolean; } /** Represents the usage of a primitive value (string, number, or bool) */ @@ -354,6 +356,7 @@ export interface UnknownValue extends ValueMeta<"unknown"> { export interface EnumValue extends ValueMetaWithTypeDef<"enum", EnumType> { readonly role: "value" | "foreignKey" | "primaryKey"; readonly rules?: Rules; + readonly defaultValue?: number; } /** Represents the usage of an 'external type', i.e. an object that is not part of a relational model */ diff --git a/src/coalesce-vue/src/model.ts b/src/coalesce-vue/src/model.ts index bb0459b97..efc1b63d5 100644 --- a/src/coalesce-vue/src/model.ts +++ b/src/coalesce-vue/src/model.ts @@ -303,14 +303,7 @@ class ModelConversionVisitor extends Visitor { // All properties that are not defined need to be declared // so that Vue's reactivity system can discover them. // Null is a valid type for all model properties (or at least generated models). Undefined is not. - let val = null; - - // Use default value if defined. - if (meta.props[propName].defaultValue !== undefined) { - val = meta.props[propName].defaultValue; - } - - target[propName] = val; + target[propName] = null; } else { target[propName] = this.visitValue(propVal, props[propName]); } diff --git a/src/coalesce-vue/src/viewmodel.ts b/src/coalesce-vue/src/viewmodel.ts index 0352baf2f..c173a5548 100644 --- a/src/coalesce-vue/src/viewmodel.ts +++ b/src/coalesce-vue/src/viewmodel.ts @@ -5,6 +5,7 @@ import { markRaw, getCurrentInstance, toRaw, + type Ref, } from "vue"; import { @@ -186,7 +187,7 @@ export abstract class ViewModel< } /** @internal */ - private _params = ref(new DataSourceParameters()); + private _params: Ref = ref(new DataSourceParameters()); /** The parameters that will be passed to `/get`, `/save`, and `/delete` calls. */ public get $params() { @@ -451,7 +452,8 @@ export abstract class ViewModel< public $saveMode: "surgical" | "whole" = "surgical"; /** @internal */ - private _savingProps = ref>(emptySet); + private _savingProps: Ref> = + ref>(emptySet); /** When `$save.isLoading == true`, contains the properties of the model currently being saved by `$save` (including autosaves). * @@ -1144,7 +1146,7 @@ export abstract class ViewModel< initialDirtyData?: DeepPartial | null ) { - this.$data = reactive(convertToModel({}, this.$metadata)); + this.$data = reactive(convertToModel({}, $metadata)); Object.defineProperty(this, "$stableId", { enumerable: true, // Enumerable so visible in vue devtools @@ -1156,6 +1158,15 @@ export abstract class ViewModel< if (initialDirtyData) { this.$loadDirtyData(initialDirtyData); } + + for (const prop of Object.values($metadata.props)) { + if ( + "defaultValue" in prop && + (!initialDirtyData || !(prop.name in initialDirtyData)) + ) { + (this as any)[prop.name] = prop.defaultValue; + } + } } } diff --git a/src/coalesce-vue/test/targets.metadata.ts b/src/coalesce-vue/test/targets.metadata.ts index 5d84bcca3..ccca5b595 100644 --- a/src/coalesce-vue/test/targets.metadata.ts +++ b/src/coalesce-vue/test/targets.metadata.ts @@ -14,11 +14,18 @@ import { BehaviorFlags, } from "../src/metadata"; -const metaBase = (name: string = "model") => { - return { +export const metaBase = (name: string = "model") => { + const pascalName = name.substr(0, 1).toUpperCase() + name.substr(1); + let obj; + return (obj = { + type: "model", name: name, - displayName: name.substr(0, 1).toUpperCase() + name.substr(1), - }; + displayName: pascalName, + dataSources: {}, + methods: {}, + controllerRoute: pascalName, + behaviorFlags: 7 as BehaviorFlags, + }); }; const value = (name: string = "prop") => { @@ -494,6 +501,6 @@ interface AppDomain extends Domain { services: {}; } -solidify(domain); +// solidify(domain); export default domain as AppDomain; diff --git a/src/coalesce-vue/test/viewmodel.spec.ts b/src/coalesce-vue/test/viewmodel.spec.ts index 1ebb2ae25..32a93c27f 100644 --- a/src/coalesce-vue/test/viewmodel.spec.ts +++ b/src/coalesce-vue/test/viewmodel.spec.ts @@ -16,6 +16,7 @@ import { ListViewModel, ViewModel, ViewModelCollection, + defineProps, } from "../src/viewmodel"; import { @@ -32,6 +33,8 @@ import { AxiosResponse } from "axios"; import { mount } from "@vue/test-utils"; import { IsVue2 } from "../src/util"; import { mockEndpoint } from "../src/test-utils"; +import { ModelType } from "../src/metadata"; +import { metaBase } from "./targets.metadata"; function mockItemResult(success: boolean, object: T) { return vitest.fn().mockResolvedValue(>{ @@ -2417,6 +2420,76 @@ describe("ViewModel", () => { // Should be the exact same reference. expect(studentVM.advisor).toBe(advisorVM); }); + + describe("default values", () => { + const meta = { + ...metaBase(), + get keyProp() { + return this.props.id; + }, + get displayProp() { + return this.props.name; + }, + props: { + id: { + name: "id", + displayName: "id", + role: "primaryKey", + type: "number", + }, + name: { + name: "name", + displayName: "Name", + role: "value", + type: "string", + defaultValue: "Allen", + }, + }, + } as ModelType; + + class TestVm extends ViewModel<{ + $metadata: typeof meta; + id: number | null; + name: string | null; + }> { + declare id: number | null; + declare name: string | null; + } + defineProps(TestVm as any, meta); + + test("populates default values as dirty", () => { + // If default values don't get marked dirty, they won't get saved + // which could result in a mismatch between what the user sees in the UI + // and what actually happens on the server. + const instance = new TestVm(meta, null!); + + expect(instance.name).toBe("Allen"); + expect(instance.$getPropDirty("name")).toBe(true); + }); + + test("default values do not overwrite initial data", () => { + const instance = new TestVm(meta, null!, { name: "Bob" }); + + expect(instance.name).toBe("Bob"); + expect(instance.$getPropDirty("name")).toBe(true); + }); + + test("default values fill holes in initial data", () => { + const instance = new TestVm(meta, null!, { id: 42 }); + + expect(instance.id).toBe(42); + expect(instance.name).toBe("Allen"); + }); + + test("default values don't supersede nulls in initial data", () => { + // When instantiating a viewmodel from an existing object, deliberate + // nulls on the existing object shouldn't be replaced by default values. + const instance = new TestVm(meta, null!, { id: 42, name: null }); + + expect(instance.id).toBe(42); + expect(instance.name).toBe(null); + }); + }); }); });