Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: default value attribute #308

Merged
merged 10 commits into from
Nov 16, 2023
7 changes: 5 additions & 2 deletions docs/modeling/model-components/attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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).
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.
2 changes: 2 additions & 0 deletions playground/Coalesce.Domain/Person.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -77,6 +78,7 @@ public Person()
/// <summary>
/// Genetic Gender of the person.
/// </summary>
[DefaultValue(Genders.NonSpecified)]
public Genders Gender { get; set; }

[NotMapped]
Expand Down
1 change: 1 addition & 0 deletions playground/Coalesce.Web.Vue2/src/metadata.g.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions playground/Coalesce.Web.Vue3/src/metadata.g.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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<string> rules = GetValidationRules(prop, (prop.ReferenceNavigationProperty ?? prop).DisplayName);

if (rules.Count > 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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; }

Expand Down
5 changes: 5 additions & 0 deletions src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,11 @@ propName is null
/// </summary>
public bool IsDateOnly => DateType == DateTypeAttribute.DateTypes.DateOnly;

/// <summary>
/// Returns the default value specified by <see cref="DefaultValueAttribute"/>, if present.
/// </summary>
public object? DefaultValue => this.GetAttributeValue<DefaultValueAttribute>(nameof(DefaultValueAttribute.Value));

/// <summary>
/// If true, there is an API controller that is serving this type of data.
/// </summary>
Expand Down
4 changes: 4 additions & 0 deletions src/coalesce-vue/src/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down Expand Up @@ -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 */
Expand Down
25 changes: 22 additions & 3 deletions src/coalesce-vue/src/viewmodel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
markRaw,
getCurrentInstance,
toRaw,
type Ref,
} from "vue";

import {
Expand Down Expand Up @@ -186,7 +187,7 @@ export abstract class ViewModel<
}

/** @internal */
private _params = ref(new DataSourceParameters());
private _params: Ref<DataSourceParameters> = ref(new DataSourceParameters());

/** The parameters that will be passed to `/get`, `/save`, and `/delete` calls. */
public get $params() {
Expand Down Expand Up @@ -451,7 +452,8 @@ export abstract class ViewModel<
public $saveMode: "surgical" | "whole" = "surgical";

/** @internal */
private _savingProps = ref<ReadonlySet<string>>(emptySet);
private _savingProps: Ref<ReadonlySet<string>> =
ref<ReadonlySet<string>>(emptySet);

/** When `$save.isLoading == true`, contains the properties of the model currently being saved by `$save` (including autosaves).
*
Expand Down Expand Up @@ -1144,7 +1146,7 @@ export abstract class ViewModel<

initialDirtyData?: DeepPartial<TModel> | null
) {
this.$data = reactive(convertToModel({}, this.$metadata));
this.$data = reactive(convertToModel({}, $metadata));

Object.defineProperty(this, "$stableId", {
enumerable: true, // Enumerable so visible in vue devtools
Expand All @@ -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;
}
}
}

Expand Down
10 changes: 8 additions & 2 deletions src/coalesce-vue/test/targets.metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
};

Expand Down
73 changes: 73 additions & 0 deletions src/coalesce-vue/test/viewmodel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
ListViewModel,
ViewModel,
ViewModelCollection,
defineProps,
} from "../src/viewmodel";

import {
Expand All @@ -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<T>(success: boolean, object: T) {
return vitest.fn().mockResolvedValue(<AxiosItemResult<T>>{
Expand Down Expand Up @@ -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);
});
});
});
});

Expand Down
Loading