diff --git a/docs/stacks/vue/layers/viewmodels.md b/docs/stacks/vue/layers/viewmodels.md index 392dc7734..4c76c7a29 100644 --- a/docs/stacks/vue/layers/viewmodels.md +++ b/docs/stacks/vue/layers/viewmodels.md @@ -238,7 +238,7 @@ Returns true if auto-save is currently active on the instance. ### Bulk saves +$bulkSave(options: BulkSaveOptions) => ItemResultPromise;" lang="ts" /> Bulk saves save all changes to an object graph in one API call and one database transaction. This includes creation, updates, and deletions of entities. @@ -252,6 +252,7 @@ On the server, each affected entity is handled through the same standard mechani For the response to a bulk save, the server will load and return the root ViewModel that `$bulkSave` was called upon, using the instance's `$params` object for the [Standard Parameters](/modeling/model-components/data-sources.md#standard-parameters). +@[import-md "start":"export interface BulkSaveOptions", "end":"\n}\n", "prepend":"``` ts", "append":"```"](../../../../src/coalesce-vue/src/viewmodel.ts) diff --git a/src/coalesce-vue/src/viewmodel.ts b/src/coalesce-vue/src/viewmodel.ts index e49c2af40..b00925f7a 100644 --- a/src/coalesce-vue/src/viewmodel.ts +++ b/src/coalesce-vue/src/viewmodel.ts @@ -578,7 +578,7 @@ export abstract class ViewModel< get $bulkSave() { const $bulkSave = this.$apiClient.$makeCaller( "item", - async function (this: ViewModel, c /*TODO: options*/) { + async function (this: ViewModel, c, options?: BulkSaveOptions) { /** The models to traverse for relations in the next iteration of the outer loop. */ let nextModels: (ViewModel | null)[] = [this]; @@ -617,6 +617,12 @@ export abstract class ViewModel< ? "save" // Save items that are dirty or never saved : "none"; // Non-dirty, non-deleted items are sent as "none". This exists so that the root item (that will be sent as the response from the server) can be identified even if it isn't being modified. + if (action !== "none") { + if (options?.predicate?.(model, action) === false) { + continue; + } + } + if (action == "save") { const errors = [...model.$getErrors()]; if (errors.length) { @@ -630,7 +636,7 @@ export abstract class ViewModel< } } - // Don't items that have an action of `none` if they aren't there to identify the root item. + // Don't include items that have an action of `none` if they aren't there to identify the root item. if (!root && action == "none") continue; const refs: BulkSaveRequestItem["refs"] = { @@ -654,8 +660,7 @@ export abstract class ViewModel< nextModels.push(...dependents); } } else if (prop.role == "referenceNavigation") { - const principal = model.$data[prop.name] as ViewModel | null; - nextModels.push(principal); + let principal = model.$data[prop.name] as ViewModel | null; // Build up `refs` as needed. // If the prop is a reference navigation that has no foreign key, @@ -667,21 +672,13 @@ export abstract class ViewModel< // If the foreign key has a value then we don't need a ref. model.$data[prop.foreignKey.name] != null ) { - continue; - } - - if ( - model.$data[prop.name] && - !model.$data[prop.name]._existsOnServer - ) { - // The model has an object value for this reference navigation. - // This makes things easy - the foreign key should ref the value of that reference navigation. - refs[prop.foreignKey.name] = model.$data[prop.name].$stableId; + nextModels.push(principal); continue; } const collection = model.$parentCollection; if ( + !principal && collection?.$parent instanceof ViewModel && collection.$metadata == prop.inverseNavigation ) { @@ -694,11 +691,21 @@ export abstract class ViewModel< // This scenario enables adding a new item to a collection navigation // without setting either the foreign key or the nav prop on that new child. - refs[prop.foreignKey.name] = collection.$parent.$stableId; + principal = collection.$parent; + } - // Ensure we traverse to the owner of the ref we just used, which since - // the reference navigation had no value, its actually possible we missed it. - nextModels.push(collection.$parent); + nextModels.push(principal); + + if ( + principal && + !principal._existsOnServer && + !principal._isRemoved && + options?.predicate?.(principal, "save") !== false + ) { + // The model has an object value for this reference navigation. + // This makes things easy - the foreign key should ref the value of that reference navigation. + refs[prop.foreignKey.name] = principal.$stableId; + continue; } } } @@ -1678,8 +1685,19 @@ type AutoSaveOptions = DebounceOptions & } ); +export interface BulkSaveOptions { + /** A predicate that will be applied to each modified model + * to determine if it should be included in the bulk save operation. + * + * The predicate is applied before validation (`$hasError`), allowing + * it to be used to skip over entities that have client validation errors + * that would otherwise cause the entire bulk save operation to fail. + * */ + predicate?: (viewModel: ViewModel, action: "save" | "delete") => boolean; +} + /** - * Dynamically adds gettter/setter properties to a class. These properties wrap the properties in its instances' $data objects. + * Dynamically adds getter/setter properties to a class. These properties wrap the properties in its instances' $data objects. * @param ctor The class to add wrapper properties to * @param metadata The metadata describing the properties to add. */ diff --git a/src/coalesce-vue/test/viewmodel.spec.ts b/src/coalesce-vue/test/viewmodel.spec.ts index 30c73c824..6248ae459 100644 --- a/src/coalesce-vue/test/viewmodel.spec.ts +++ b/src/coalesce-vue/test/viewmodel.spec.ts @@ -511,6 +511,67 @@ describe("ViewModel", () => { endpoint.destroy(); }); + test("unsaved principal excluded by predicate", async () => { + const response = { + refMap: {} as any, + object: { + studentId: 1, + name: "scott", + }, + }; + + const endpoint = mockEndpoint( + "/students/bulkSave", + vitest.fn((req) => ({ + wasSuccessful: true, + ...JSON.parse(JSON.stringify(response)), // deep clone + })) + ); + + const student = new StudentViewModel({ name: "scott" }); + + // new parent, with children not added by $addChild. + student.advisor = new AdvisorViewModel({ name: "bob" }); + // Add a failing validation rule to ensure that failed validation + // on predicate-excluded models does not block saves. + student.advisor.$addRule("name", "test", (v) => v == "foo" || "invalid"); + const originalAdvisor = student.advisor; + + // Setup the ref map on the response so that existing instances may be preserved + response.refMap[student.$stableId] = 1; + + await student.$bulkSave({ + predicate(entity, action) { + if (entity instanceof AdvisorViewModel) return false; + return true; + }, + }); + + expect(JSON.parse(endpoint.mock.calls[0][0].data)).toMatchObject({ + items: expect.arrayContaining([ + { + action: "save", + type: "Student", + data: { studentId: null, studentAdvisorId: null, name: "scott" }, + refs: { + studentId: student.$stableId, + // There should NOT be a ref for `studentAdvisorId` since the + // unsaved advisor was excluded by predicate. + }, + root: true, + }, + ]), + }); + + // Preserves non-circular instances: + // Reference nav: + expect(student.advisor === originalAdvisor).toBeTruthy(); + expect(student.$bulkSave.wasSuccessful).toBeTruthy(); + expect(student.studentId).toBe(1); + + endpoint.destroy(); + }); + test("deletion", async () => { const loadEndpoint = mockEndpoint("/students/get", (req) => ({ wasSuccessful: true,