Skip to content

Commit

Permalink
feat: #334 add autosave to ListViewModel
Browse files Browse the repository at this point in the history
  • Loading branch information
ascott18 committed Feb 1, 2024
1 parent 5c0bbf2 commit 08473d4
Show file tree
Hide file tree
Showing 5 changed files with 178 additions and 19 deletions.
16 changes: 16 additions & 0 deletions docs/stacks/vue/layers/viewmodels.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,22 @@ type AutoLoadOptions<TThis> =
Manually turns off auto-loading of the instance.
### Auto-save
<Prop def="// Vue Options API
$startAutoSave(vue: Vue, options: AutoSaveOptions<this> = {})
&nbsp;
// Vue Composition API
$useAutoSave(options: AutoSaveOptions<this> = {})" lang="ts" idPrefix="list-autosave" />
Enables auto-save for the items in the list, propagating to new items as they're added or loaded. See [ViewModel auto-save documentation](#member-autosave) for more details.
<Prop def="$stopAutoSave(): void" lang="ts" />
Turns off auto-saving of the items in the list, and turns of propagation of auto-save to any future items if auto-save was previously turned on for the list. Only affects items that are currently in the list's `$items`.
### Generated Members
Expand Down
14 changes: 7 additions & 7 deletions src/coalesce-vue-vuetify2/src/components/admin/c-admin-table.vue
Original file line number Diff line number Diff line change
Expand Up @@ -237,15 +237,15 @@ export default defineComponent({
}
this.$watch(
() => [this.editable, ...this.viewModel.$items.map((i) => i.$stableId)],
() => {
if (this.editable) {
for (const item of this.viewModel.$items) {
item.$startAutoSave(this, { wait: 100 });
}
() => this.editable,
(editable) => {
if (editable && !this.viewModel.$isAutoSaveEnabled) {
this.viewModel.$startAutoSave(this, { wait: 100 });
} else if (!editable && this.viewModel.$isAutoSaveEnabled) {
this.viewModel.$stopAutoSave();
}
},
{ immediate: true, deep: true }
{ immediate: true }
);
this.viewModel.$load.setConcurrency("debounce");
Expand Down
14 changes: 7 additions & 7 deletions src/coalesce-vue-vuetify3/src/components/admin/c-admin-table.vue
Original file line number Diff line number Diff line change
Expand Up @@ -234,15 +234,15 @@ export default defineComponent({
}
this.$watch(
() => [this.editable, ...this.viewModel.$items.map((i) => i.$stableId)],
() => {
if (this.editable) {
for (const item of this.viewModel.$items) {
item.$startAutoSave(this, { wait: 100 });
}
() => this.editable,
(editable) => {
if (editable && !this.viewModel.$isAutoSaveEnabled) {
this.viewModel.$startAutoSave(this, { wait: 100 });
} else if (!editable) {
this.viewModel.$stopAutoSave();
}
},
{ immediate: true, deep: true }
{ immediate: true }
);
this.viewModel.$load.setConcurrency("debounce");
Expand Down
73 changes: 69 additions & 4 deletions src/coalesce-vue/src/viewmodel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1077,7 +1077,7 @@ export abstract class ViewModel<

/** Stops auto-saving if it is currently enabled. */
public $stopAutoSave() {
this._autoSaveState?.cleanup!();
this._autoSaveState?.cleanup?.();
}

/** Returns true if auto-saving is currently enabled. */
Expand Down Expand Up @@ -1418,6 +1418,68 @@ export abstract class ListViewModel<
this._autoLoadState.cleanup?.();
}

/** @internal Internal autosave state */
private _autoSaveState = new AutoCallState();

/**
* Enables auto save for the items in the list.
* Only usable from Vue setup() or <script setup>. Otherwise, use $startAutoSave().
* @param options Options to control how the auto-saving is performed.
*/
public $useAutoSave(options: AutoSaveOptions<this> = {}) {
const vue = getCurrentInstance()?.proxy;
if (!vue)
throw new Error(
"$useAutoSave can only be used inside setup(). Consider using $startAutoSave if you're not using Vue composition API."
);
return this.$startAutoSave(vue, options);
}

/**
* Enables auto save for the items in the list.
* @param vue A Vue instance through which the lifecycle of the watcher will be managed.
* @param options Options to control how the auto-saving is performed.
*/
public $startAutoSave(vue: VueInstance, options: AutoSaveOptions<this> = {}) {
let state = this._autoSaveState;

if (state?.active && state.options === options) {
// If already active using the exact same options object, don't restart.
return;
}

this.$stopAutoSave();

state = this._autoSaveState ??= new AutoCallState<AutoSaveOptions<any>>();
state.options = options;
state.vue = vue;

const watcher = vue.$watch(
() => [...this.$items.map((i) => i.$stableId)],
() => {
for (const item of this.$items) {
item.$startAutoSave(state.vue!, state.options);
}
},
{ immediate: true, deep: true }
);

startAutoCall(state, vue, watcher);
}

/** Stops auto-saving if it is currently enabled. */
public $stopAutoSave() {
this._autoSaveState?.cleanup?.();
for (const item of this.$items) {
item.$stopAutoSave();
}
}

/** Returns true if auto-saving is currently enabled. */
public get $isAutoSaveEnabled() {
return this._autoSaveState?.active;
}

constructor(
// The following MUST be declared in the constructor so its value will be available to property initializers.

Expand Down Expand Up @@ -2305,7 +2367,7 @@ function startAutoCall(
// Only cleanup if the component instance on the state is the owner of the hook.
// Since we can't cleanup hooks in vue3, this hook may be firing for a component
// that no longer owns this autocall state, in which case it should be ignored.
() => state.vue === vue && state.cleanup!(),
() => state.vue === vue && state.cleanup?.(),
getInternalInstance(vue)
);
}
Expand All @@ -2318,10 +2380,13 @@ function startAutoCall(
watcher?.();

// Cancel the debouncing timer if there is one.
if (debouncer) debouncer.cancel();
debouncer?.cancel();

state.active = false;
state.vue = null; // cleanup for GC

// cleanup for GC
state.vue = null;
state.cleanup = null;
};
state.active = true;
}
Expand Down
80 changes: 79 additions & 1 deletion src/coalesce-vue/test/viewmodel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ import {
defineProps,
} from "../src/viewmodel";

import { ComplexModelViewModel } from "../../test-targets/viewmodels.g";
import {
CaseListViewModel,
CaseViewModel,
ComplexModelListViewModel,
ComplexModelViewModel,
} from "../../test-targets/viewmodels.g";
import {
StudentViewModel,
CourseViewModel,
Expand Down Expand Up @@ -2874,4 +2879,77 @@ describe("ListViewModel", () => {
expect((list as any)._autoLoadState.active).toBe(false);
});
});

describe("autosave", () => {
test("propagates to new items", async () => {
const list = new CaseListViewModel();
const vue = mountData({ list });

const saveMock = mockEndpoint(
"/Case/save",
vitest.fn(() => ({ wasSuccessful: true }))
);

list.$startAutoSave(vue, { wait: 0 });
const item = new CaseViewModel({ title: "bob" });
list.$items.push(item);

expect(item.$isDirty).toBe(true);
expect([...item.$getErrors()]).toHaveLength(0);

await delay(10);

expect(item.$isDirty).toBe(false);
expect(saveMock).toBeCalledTimes(1);
});

test("propagates to existing items", async () => {
const list = new CaseListViewModel();
const vue = mountData({ list });

const saveMock = mockEndpoint(
"/Case/save",
vitest.fn(() => ({ wasSuccessful: true }))
);

const item = new CaseViewModel({ title: "bob" });
list.$items.push(item);

list.$startAutoSave(vue, { wait: 0 });

await delay(10);

expect(item.$isDirty).toBe(false);
expect(saveMock).toBeCalledTimes(1);
});

test("stops on existing items", async () => {
const list = new CaseListViewModel();
const vue = mountData({ list });

const saveMock = mockEndpoint(
"/Case/save",
vitest.fn(() => ({ wasSuccessful: true }))
);

const item = new CaseViewModel({ title: "foo" });
item.$isDirty = false;
list.$items.push(item);

list.$startAutoSave(vue, { wait: 0 });
list.$stopAutoSave();

item.title = "bar";

await delay(10);

expect(item.$isDirty).toBe(true);
expect(saveMock).toBeCalledTimes(0);

const item2 = new CaseViewModel();
list.$items.push(item2);
await delay(10);
expect(item2.$isAutoSaveEnabled).toBeFalsy();
});
});
});

0 comments on commit 08473d4

Please sign in to comment.