Skip to content

Commit

Permalink
feat: add predicate option to $bulkSave
Browse files Browse the repository at this point in the history
  • Loading branch information
ascott18 committed Nov 8, 2023
1 parent d9ede6b commit b0f2daa
Show file tree
Hide file tree
Showing 3 changed files with 100 additions and 20 deletions.
3 changes: 2 additions & 1 deletion docs/stacks/vue/layers/viewmodels.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ Returns true if auto-save is currently active on the instance.
### Bulk saves
<Prop def="$bulkSave: ItemApiState;
$bulkSave() => ItemResultPromise<TModel>;" lang="ts" />
$bulkSave(options: BulkSaveOptions) => ItemResultPromise<TModel>;" 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.
Expand All @@ -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)
<Prop def="$remove(): void" lang="ts" />
Expand Down
56 changes: 37 additions & 19 deletions src/coalesce-vue/src/viewmodel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down Expand Up @@ -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) {
Expand All @@ -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"] = {
Expand All @@ -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,
Expand All @@ -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
) {
Expand All @@ -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;
}
}
}
Expand Down Expand Up @@ -1678,8 +1685,19 @@ type AutoSaveOptions<TThis> = 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.
*/
Expand Down
61 changes: 61 additions & 0 deletions src/coalesce-vue/test/viewmodel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit b0f2daa

Please sign in to comment.