From 59de026cec548799333ddd3dc8502b32a8b15893 Mon Sep 17 00:00:00 2001 From: Victor Korn Date: Wed, 15 Nov 2023 16:38:40 -0800 Subject: [PATCH] feat: default value attribute (#308) Co-authored-by: Andrew Scott --- docs/modeling/model-components/attributes.md | 7 +- playground/Coalesce.Domain/Person.cs | 2 + .../Coalesce.Web.Vue2/src/metadata.g.ts | 1 + .../Coalesce.Web.Vue3/src/metadata.g.ts | 1 + .../Generators/Scripts/TsMetadata.cs | 32 ++++++++ .../TestDbContext/ComplexModel.cs | 13 ++++ .../TypeDefinition/PropertyViewModel.cs | 5 ++ src/coalesce-vue/src/metadata.ts | 4 + src/coalesce-vue/src/viewmodel.ts | 25 ++++++- src/coalesce-vue/test/targets.metadata.ts | 10 ++- src/coalesce-vue/test/viewmodel.spec.ts | 73 +++++++++++++++++++ 11 files changed, 166 insertions(+), 7 deletions(-) diff --git a/docs/modeling/model-components/attributes.md b/docs/modeling/model-components/attributes.md index 0c9b9538e..be2fabec6 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. @@ -57,4 +56,8 @@ Primary Keys with `[DatabaseGenerated(DatabaseGeneratedOption.None)]` will be se ### [NotMapped] -Model properties that aren't mapped to the database should be marked with `[NotMapped]` so that Coalesce doesn't try to load them from the database when [searching](/modeling/model-components/attributes/search.md) or carrying out the [Default Loading Behavior](/modeling/model-components/data-sources.md#default-loading-behavior). \ No newline at end of file +Model properties that aren't mapped to the database should be marked with `[NotMapped]` so that Coalesce doesn't try to load them from the database when [searching](/modeling/model-components/attributes/search.md) or carrying out the [Default Loading Behavior](/modeling/model-components/data-sources.md#default-loading-behavior). + +### [DefaultValue] + +Properties with `[DefaultValue]` will receive the specified value when a new ViewModel is instantiated on the client. This enables scenarios like pre-filling a required property with a suggested value. diff --git a/playground/Coalesce.Domain/Person.cs b/playground/Coalesce.Domain/Person.cs index 24df14067..6692af05c 100644 --- a/playground/Coalesce.Domain/Person.cs +++ b/playground/Coalesce.Domain/Person.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using System; using System.Collections.Generic; +using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Linq; @@ -77,6 +78,7 @@ public Person() /// /// Genetic Gender of the person. /// + [DefaultValue(Genders.NonSpecified)] public Genders Gender { get; set; } [NotMapped] 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/playground/Coalesce.Web.Vue3/src/metadata.g.ts b/playground/Coalesce.Web.Vue3/src/metadata.g.ts index 0a549ab5c..aad19554d 100644 --- a/playground/Coalesce.Web.Vue3/src/metadata.g.ts +++ b/playground/Coalesce.Web.Vue3/src/metadata.g.ts @@ -1220,6 +1220,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 b47c5e3cb..ecdd1ff5e 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs @@ -305,6 +305,38 @@ 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) 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 5c015f4be..b8fefd195 100644 --- a/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs +++ b/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs @@ -244,6 +244,11 @@ propName is null /// public bool IsDateOnly => DateType == DateTypeAttribute.DateTypes.DateOnly; + /// + /// 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. /// diff --git a/src/coalesce-vue/src/metadata.ts b/src/coalesce-vue/src/metadata.ts index e5601f1f7..8176a9829 100644 --- a/src/coalesce-vue/src/metadata.ts +++ b/src/coalesce-vue/src/metadata.ts @@ -302,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) */ @@ -353,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/viewmodel.ts b/src/coalesce-vue/src/viewmodel.ts index 0352baf2f..f04de24d6 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,23 @@ export abstract class ViewModel< if (initialDirtyData) { this.$loadDirtyData(initialDirtyData); } + + const ctor = this.constructor as any; + if (ctor.hasPropDefaults !== false) { + for (const prop of Object.values($metadata.props)) { + if ("defaultValue" in prop) { + ctor.hasPropDefaults ??= true; + + if (!initialDirtyData || !(prop.name in initialDirtyData)) { + (this as any)[prop.name] = prop.defaultValue; + } + } + } + + // Cache that this type doesn't have prop defaults so we don't + // ever have to loop over the props looking for them on future instances. + ctor.hasPropDefaults ??= false; + } } } diff --git a/src/coalesce-vue/test/targets.metadata.ts b/src/coalesce-vue/test/targets.metadata.ts index 5d84bcca3..306a962af 100644 --- a/src/coalesce-vue/test/targets.metadata.ts +++ b/src/coalesce-vue/test/targets.metadata.ts @@ -14,10 +14,16 @@ import { BehaviorFlags, } from "../src/metadata"; -const metaBase = (name: string = "model") => { +export const metaBase = (name: string = "model") => { + const pascalName = name.substr(0, 1).toUpperCase() + name.substr(1); return { + type: "model", name: name, - displayName: name.substr(0, 1).toUpperCase() + name.substr(1), + displayName: pascalName, + dataSources: {}, + methods: {}, + controllerRoute: pascalName, + behaviorFlags: 7 as BehaviorFlags, }; }; 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); + }); + }); }); });