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);
+ });
+ });
});
});