Skip to content

Commit

Permalink
feat: default value attribute (#308)
Browse files Browse the repository at this point in the history
Co-authored-by: Andrew Scott <[email protected]>
  • Loading branch information
vicdotexe and ascott18 authored Nov 16, 2023
1 parent fa1639c commit 59de026
Show file tree
Hide file tree
Showing 11 changed files with 166 additions and 7 deletions.
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

0 comments on commit 59de026

Please sign in to comment.