Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,8 @@ type PropertyMetadata = {
post?: "always" | "pathOnly" | "no";
update?: "always" | "no";
validationRules?: PropertyValidationRuleInfo[];
clientExtenders?: ClientExtenderInfo[]
clientExtenders?: ClientExtenderInfo[];
isKey?: boolean;
}

type TypeDefinition = string |
Expand Down
23 changes: 23 additions & 0 deletions src/Framework/Framework/Resources/Scripts/metadata/typeMap.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { keys } from "../utils/objects";
import { primitiveTypes } from "./primitiveTypes";


let types: TypeMap = {};
Expand Down Expand Up @@ -92,3 +93,25 @@ export function formatTypeName(type: TypeDefinition, prefix = "", suffix = ""):
const typeCheck: never = type
return undefined as any
}

type KeyFunction = (item: any) => string;
const keyFunctions: { [name: string]: KeyFunction | undefined } = {};
export function tryGetKeyFunction(type: string): KeyFunction | undefined {
if (type in keyFunctions) {
return keyFunctions[type];
}
return keyFunctions[type] = buildKeyFunction(type);
}
function buildKeyFunction(type: string): KeyFunction | undefined {
if (!(type in primitiveTypes)) {
const typeInfo = getTypeInfo(type);
if (typeInfo.type === "object") {
const props = getTypeProperties(type);
// NB: validation that property type is primitive or nullable is done on the server
const keyProperties = Object.entries(props).filter(e => e[1].isKey).map(e => e[0]);
if (keyProperties.length) {
return (item: any) => JSON.stringify(keyProperties.map(p => ko.unwrap(ko.unwrap(item)?.[p])));
}
}
}
}
40 changes: 35 additions & 5 deletions src/Framework/Framework/Resources/Scripts/state-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
import { createArray, defineConstantProperty, isPrimitive, keys } from "./utils/objects";
import { DotvvmEvent } from "./events";
import { extendToObservableArrayIfRequired } from "./serialization/deserialize"
import { areObjectTypesEqual, formatTypeName, getObjectTypeInfo } from "./metadata/typeMap";
import { areObjectTypesEqual, formatTypeName, getObjectTypeInfo, tryGetKeyFunction } from "./metadata/typeMap";
import { coerce } from "./metadata/coercer";
import { patchViewModel } from "./postback/updater";
import { hackInvokeNotifySubscribers } from "./utils/knockout";
import { logWarning } from "./utils/logging";
import {ValidationError} from "./validation/error";
import { errorsSymbol } from "./validation/common";
import { getTypeProperties } from "./metadata/typeMap";
import { primitiveTypes } from "./metadata/primitiveTypes";


export const currentStateSymbol = Symbol("currentState")
Expand Down Expand Up @@ -313,6 +315,7 @@ function logObservableCloneWarning(value: any) {
function createWrappedObservable<T>(initialValue: DeepReadonly<T>, typeHint: TypeDefinition | undefined, getter: () => DeepReadonly<T> | undefined, updater: UpdateDispatcher<T>): DeepKnockoutObservable<T> {

let isUpdating = false
const arrayElementKeyFunction = (typeHint instanceof Array && typeof typeHint[0] === "string") ? tryGetKeyFunction(typeHint[0]) : void 0;

function observableValidator(this: KnockoutObservable<T>, newValue: any): any {
if (isUpdating) return { newValue, notifySubscribers: false }
Expand Down Expand Up @@ -385,12 +388,39 @@ function createWrappedObservable<T>(initialValue: DeepReadonly<T>, typeHint: Typ
// notifiable observables
// otherwise, we want to skip the big update whenever possible - Knockout tends to update everything in the DOM when
// we update the observableArray
const skipUpdate = !observableWasSetFromOutside && oldContents instanceof Array && oldContents.length == newVal.length
const skipUpdate = !observableWasSetFromOutside && oldContents instanceof Array && oldContents.length == newVal.length && !arrayElementKeyFunction;

if (!skipUpdate) {
const t: KnockoutObservableArray<any> = obs as any
// take at most newVal.length from the old value
newContents = oldContents instanceof Array ? oldContents.slice(0, newVal.length) : []
if (arrayElementKeyFunction && oldContents instanceof Array) {
// build a map of keys from non-null items in the old array while handling items with the same key
const oldElementsMap: { [name: string]: any[] } = {};
for (let [key, value] of oldContents.filter(i => ko.unwrap(i) !== null).map((item: any) => [arrayElementKeyFunction(item), item])) {
if (!(key in oldElementsMap)) {
oldElementsMap[key] = [value]
} else {
oldElementsMap[key].push(value)
}
}

// try to reuse existing references in the new array while handling items with the same key
newContents = Array(newVal.length);
for (let index = 0; index < newVal.length; index++) {
const key = arrayElementKeyFunction(newVal[index]);
const oldElement = oldElementsMap[key];
if (oldElement) {
newContents[index] = oldElement.shift();
if (!oldElement.length) {
delete oldElementsMap[key];
}
}
}

}
else {
// take at most newVal.length from the old value
newContents = oldContents instanceof Array ? oldContents.slice(0, newVal.length) : []
}
// then append (potential) new values into the array
for (let index = 0; index < newVal.length; index++) {
if (isDotvvmObservable(newContents[index])) {
Expand Down Expand Up @@ -441,7 +471,7 @@ function createWrappedObservable<T>(initialValue: DeepReadonly<T>, typeHint: Typ
// return a result indicating that the observable needs to be set
return { newContents };
}

obs[notifySymbol] = notify
notify(initialValue)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ initDotvvm({
P2: 2,
P3: 3
},
Inner2: null
Inner2: null,
ArrayWithKeys: [
{ $type: "t6", Id: 1, SubId: "a" },
{ $type: "t6", Id: 1, SubId: "b" },
{ $type: "t6", Id: 2, SubId: null }
]
},
typeMetadata: {
t1: {
Expand Down Expand Up @@ -51,6 +56,9 @@ initDotvvm({
DateTime: { type: { type: "nullable", inner: "DateTime" } },
Dynamic: {
type: { type: "dynamic" }
},
ArrayWithKeys: {
type: ["t6"]
}
}
},
Expand Down Expand Up @@ -106,6 +114,19 @@ initDotvvm({
}
}
},
t6: {
type: "object",
properties: {
"Id": {
type: "Int32",
"isKey": true
},
"SubId": {
type: "String",
"isKey": true
}
}
},
e1: {
type: "enum",
isFlags: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -851,3 +851,22 @@ test("ko.observableArray - clones pushed dotvvm object taken from elsewhere", ()
// expect(arr.state).toEqual(ko.toJS(arr))
// })
// })


test("ko.observableArray - synchronizing objects by key", () => {
const ref0 = vm.ArrayWithKeys()[0];
const ref1 = vm.ArrayWithKeys()[1];
const ref2 = vm.ArrayWithKeys()[2];

s.patchState({
ArrayWithKeys: [
{ Id: 2, SubId: null },
{ Id: 3, SubId: null }
]
});
s.doUpdateNow();

expect(vm.ArrayWithKeys()[0]).toBe(ref2);
expect(vm.ArrayWithKeys()[1]).not.toBe(ref0);
expect(vm.ArrayWithKeys()[1]).not.toBe(ref1);
})
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.IO;
using System.Linq;
Expand Down Expand Up @@ -150,6 +151,15 @@ private ObjectMetadataWithDependencies BuildObjectTypeMetadata(ViewModelSerializ
JsonSerializer.Serialize(json, property.ClientExtenders, DefaultSerializerSettingsProvider.Instance.SettingsHtmlUnsafe);
}

if (property.PropertyInfo.GetCustomAttribute<KeyAttribute>() is { })
{
if (!ReflectionUtils.IsPrimitiveType(property.Type))
{
throw new NotSupportedException($"Property {property.Name} on type {map.Type} defines the [Key] attribute, but is not of a primitive type.");
}
json.WriteBoolean("isKey"u8, true);
}

json.WriteEndObject();
}

Expand Down
6 changes: 3 additions & 3 deletions src/Framework/Framework/tsconfig.jest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
"noEmitOnError": false,
"removeComments": false,
"sourceMap": true,
"target": "ES2018",
"target": "ES2020",
"declaration": true,
"strictNullChecks": true,
"lib": [ "dom", "es2015.promise", "es5", "es6" ],
"lib": [ "dom", "ES2020" ],
"module": "ES2015",
"esModuleInterop": true,
"skipLibCheck": true
},
"exclude": [ "obj/**", "node_modules/**" ],
"compileOnSave": false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using DotVVM.Framework.ViewModel;
using DotVVM.Framework.Hosting;

namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.CollectionKeys
{
public class CollectionKeysViewModel : DotvvmViewModelBase
{
public string NewTaskTitle { get; set; }

public List<TaskViewModel> Tasks { get; set; }

public CollectionKeysViewModel()
{
Tasks = new List<TaskViewModel>();
}

public override Task Init()
{
if (!Context.IsPostBack)
{
Tasks.Add(new TaskViewModel() { IsCompleted = false, TaskId = Guid.NewGuid(), Title = "Do the laundry" });
Tasks.Add(new TaskViewModel() { IsCompleted = false, TaskId = Guid.NewGuid(), Title = "Wash the car" });
Tasks.Add(new TaskViewModel() { IsCompleted = false, TaskId = Guid.NewGuid(), Title = "Go shopping" });
}

return base.Init();
}

public void AddTask()
{
Tasks.Add(new TaskViewModel() { Title = NewTaskTitle, TaskId = Guid.NewGuid() });
NewTaskTitle = string.Empty;
}

public void CompleteTask(Guid id)
{
Tasks.RemoveAll(t => t.TaskId == id);
}

}

public class TaskViewModel
{
[Key]
public Guid TaskId { get; set; }

public string Title { get; set; }

public bool IsCompleted { get; set; }
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.CollectionKeys.CollectionKeysViewModel, DotVVM.Samples.Common

<!DOCTYPE html>

<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>Hello from DotVVM!</title>
<style>
.fade {
opacity: 0;
transition: opacity ease-in-out 1s;

&.show {
opacity: 1;
}
}
</style>
</head>
<body>

<dot:InlineScript Dependencies="knockout">
function afterRender(nodes) {
for (let child of nodes) {
if (child.nodeType === Node.ELEMENT_NODE) {
child.classList.add("show");
}
}
}
function afterAdd(node) {
if (node.nodeType === Node.ELEMENT_NODE) {
node.classList.remove("show");
window.requestAnimationFrame(() => {
node.classList.add("show");
}, 1);
}
}
function beforeRemove(node) {
if (node.nodeType === Node.ELEMENT_NODE) {
node.addEventListener("transitionend", () => {
node.remove();
}, { once: true });
node.classList.remove("show");
} else {
node.remove();
}
}

function makeAnimatedValueAccessor(valueAccessor) {
return () => ({
...valueAccessor(),
afterRender,
afterAdd,
beforeRemove
});
}

const originalTemplateHandler = ko.bindingHandlers["template"];
ko.bindingHandlers["template"] = {
init: function (element, valueAccessor, allBindings, viewModel, bindingContext) {
return originalTemplateHandler['init'](element, makeAnimatedValueAccessor(valueAccessor), allBindings, viewModel, bindingContext);
},
'update': function (element, valueAccessor, allBindings, viewModel, bindingContext) {
return originalTemplateHandler['update'](element, makeAnimatedValueAccessor(valueAccessor), allBindings, viewModel, bindingContext);
}
};
</dot:InlineScript>

<div class="container">
<h1>Task List</h1>

<form>
<fieldset data-testattribute>
<legend>Add Task</legend>

<p>Title: <dot:TextBox Text={value: NewTaskTitle} /></p>
<p><dot:Button Text="Create" Click={command: AddTask()} IsSubmitButton /></p>
</fieldset>
</form>

<p>&nbsp;</p>

<table class="table">
<dot:Repeater DataSource={value: Tasks} WrapperTagName="tbody">
<tr class="fade">
<td>{{value: Title}}</td>
<td>
<dot:LinkButton Text="done"
Click={command: _parent.CompleteTask(TaskId)} />
</td>
</tr>
</dot:Repeater>
</table>
</div>
</body>
</html>