Skip to content

Commit

Permalink
refactor: move default value population to viewmodel ctor in order to…
Browse files Browse the repository at this point in the history
… mark defaulted props as dirty and to not interfere with API responses
  • Loading branch information
ascott18 committed Nov 16, 2023
1 parent 68b3840 commit e470112
Show file tree
Hide file tree
Showing 10 changed files with 861 additions and 746 deletions.
3 changes: 1 addition & 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 @@ -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).
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.
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,463 changes: 739 additions & 724 deletions src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs

Large diffs are not rendered by default.

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
6 changes: 3 additions & 3 deletions src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
using System.Linq.Expressions;
using IntelliTect.Coalesce.TypeDefinition.Enums;
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore;

namespace IntelliTect.Coalesce.TypeDefinition
{
Expand Down Expand Up @@ -246,11 +245,12 @@ propName is null
public bool IsDateOnly => DateType == DateTypeAttribute.DateTypes.DateOnly;

/// <summary>
/// Returns a default value if provided.
/// 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>
/// If true, there is an API controller that is serving this type of data.
/// </summary>
public bool HasValidValues => IsManytoManyCollection || ((Object?.IsDbMappedType ?? false) && IsPOCO);

Expand Down
5 changes: 4 additions & 1 deletion src/coalesce-vue/src/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,6 @@ export interface ValueMeta<TType extends TypeDiscriminator> extends Metadata {
readonly role: ValueRole;
readonly type: TType;
readonly description?: string;
readonly defaultValue?: string | number | boolean;
}

/**
Expand Down Expand Up @@ -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) */
Expand Down Expand Up @@ -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 */
Expand Down
9 changes: 1 addition & 8 deletions src/coalesce-vue/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,14 +303,7 @@ class ModelConversionVisitor extends Visitor<any, any[] | null, any | null> {
// 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]);
}
Expand Down
17 changes: 14 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,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;
}
}
}
}

Expand Down
17 changes: 12 additions & 5 deletions src/coalesce-vue/test/targets.metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") => {
Expand Down Expand Up @@ -494,6 +501,6 @@ interface AppDomain extends Domain {
services: {};
}

solidify(domain);
// solidify(domain);

export default domain as AppDomain;
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 e470112

Please sign in to comment.