Skip to content
Merged
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
8 changes: 8 additions & 0 deletions app/assets/css/preferences.css
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,11 @@
height: 2.5rem;
width: 8rem;
}

.setting-not-applicable-text {
display: block;
margin-top: 0.5rem;
font-size: 0.875rem;
font-style: italic;
color: var(--p-text-muted-color);
}
31 changes: 24 additions & 7 deletions app/components/preferences/PreferencesDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,31 @@ const loading = ref(false);
const toast = useToast();
const store = useNodeSettingsStore();

const draftRequireDataStore = ref(store.requireDataStore);
const draftRequireDataStore = ref(store.requireDataStoreSetting);
// Aggregator nodes never require a data store
const requireDataStoreDisabled = computed(
() => store.nodeType === "aggregator",
);
const draftAutostart = ref<AutostartSettings>({
enabled: store.autostartEnabled,
interval: store.autostartInterval,
});

const settingsLoaded = computed(() => store.settings !== null);

function resetDrafts() {
draftRequireDataStore.value = store.requireDataStoreSetting;
draftAutostart.value.enabled = store.autostartEnabled;
draftAutostart.value.interval = store.autostartInterval;
}

watch(preferencesVisible, (visible) => {
if (visible) {
draftRequireDataStore.value = store.requireDataStore;
draftAutostart.value.enabled = store.autostartEnabled;
draftAutostart.value.interval = store.autostartInterval;
}
if (visible) resetDrafts();
});

// Update settings once fetched
watch(settingsLoaded, (loaded) => {
if (loaded) resetDrafts();
});

async function onSubmitPreferences() {
Expand Down Expand Up @@ -68,7 +81,10 @@ async function onSubmitPreferences() {
>
<Divider />
<div class="data-store-requirement-section">
<RequireDataStoreField v-model="draftRequireDataStore" />
<RequireDataStoreField
v-model="draftRequireDataStore"
:disabled="requireDataStoreDisabled"
/>
</div>
<Divider />
<div class="autostart-section">
Expand All @@ -81,6 +97,7 @@ async function onSubmitPreferences() {
size="small"
icon="pi pi-save"
:loading="loading"
:disabled="!settingsLoaded"
@click="onSubmitPreferences()"
></Button>
</div>
Expand Down
12 changes: 11 additions & 1 deletion app/components/preferences/RequireDataStoreField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import ToggleSwitch from "primevue/toggleswitch";

const model = defineModel<boolean>({ required: true });

withDefaults(defineProps<{ disabled?: boolean }>(), { disabled: false });
</script>

<template>
Expand All @@ -12,9 +14,17 @@ const model = defineModel<boolean>({ required: true });
>Toggle to control whether an analysis requires a data store before
being started.</span
>
<span v-if="disabled" class="setting-not-applicable-text"
>Aggregator nodes never require a data store, so this setting has no
effect here.</span
>
</div>
<div class="settings-control data-store-requirement-toggle">
<ToggleSwitch v-model="model" label="Require Data Store" />
<ToggleSwitch
v-model="model"
:disabled="disabled"
label="Require Data Store"
/>
</div>
</div>
</template>
5 changes: 4 additions & 1 deletion app/stores/nodeSettingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ export const useNodeSettingsStore = defineStore("nodeSettings", {
nodeType: null as string | null,
}),
getters: {
requireDataStoreSetting(state): boolean {
return Boolean(state.settings?.require_data_store ?? true);
},
requireDataStore(state): boolean {
if (state.nodeType === "aggregator") return false;
return Boolean(state.settings?.require_data_store ?? true);
return this.requireDataStoreSetting;
},
autostartEnabled(state): boolean {
return Boolean(state.settings?.autostart?.enabled ?? false);
Expand Down
3 changes: 3 additions & 0 deletions test/components/preferences/PreferencesDialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ describe("PreferencesDialog.vue", () => {
mockUpdateSettings = vi.fn().mockResolvedValue(defaultSettingsResponse);
vi.mocked(useNodeSettingsStore).mockReturnValue({
requireDataStore: true,
requireDataStoreSetting: true,
autostartEnabled: false,
autostartInterval: 60,
updateSettings: mockUpdateSettings,
Expand Down Expand Up @@ -91,6 +92,7 @@ describe("PreferencesDialog.vue", () => {
it("initializes RequireDataStoreField as false when store has false", () => {
vi.mocked(useNodeSettingsStore).mockReturnValue({
requireDataStore: false,
requireDataStoreSetting: false,
autostartEnabled: true,
autostartInterval: 30,
updateSettings: mockUpdateSettings,
Expand Down Expand Up @@ -159,6 +161,7 @@ describe("PreferencesDialog.vue", () => {
it("resets draft values from the store when the dialog reopens", async () => {
vi.mocked(useNodeSettingsStore).mockReturnValue({
requireDataStore: false,
requireDataStoreSetting: false,
autostartEnabled: true,
autostartInterval: 30,
updateSettings: mockUpdateSettings,
Expand Down
235 changes: 235 additions & 0 deletions test/components/preferences/PreferencesDialogStore.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { flushPromises, mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import PreferencesDialog from "~/components/preferences/PreferencesDialog.vue";
import { useNodeSettingsStore } from "~/stores/nodeSettingsStore";
import type { UserSettings } from "~/services/Api";

const DialogStub = {
props: ["visible", "header", "modal", "style"],
template: '<div v-if="visible" class="dialog-stub"><slot /></div>',
};

const RequireDataStoreFieldStub = {
name: "RequireDataStoreField",
props: ["modelValue", "disabled"],
emits: ["update:modelValue"],
template:
'<button class="require-datastore-stub" :data-value="String(modelValue)" :data-disabled="String(disabled)" @click="$emit(\'update:modelValue\', !modelValue)" />',
};

const AutostartFieldStub = {
name: "AutostartField",
props: ["modelValue"],
emits: ["update:modelValue"],
template:
'<div class="autostart-stub" :data-enabled="String(modelValue.enabled)" :data-interval="String(modelValue.interval)" />',
};

const globalStubs = {
teleport: true,
Dialog: DialogStub,
RequireDataStoreField: RequireDataStoreFieldStub,
AutostartField: AutostartFieldStub,
Divider: true,
};

describe("PreferencesDialog.vue + nodeSettingsStore", () => {
beforeEach(() => {
setActivePinia(createPinia());
});

function seedStore(nodeType: string, settings: UserSettings) {
const store = useNodeSettingsStore();
store.settings = settings;
store.nodeType = nodeType;
store.updateSettings = vi.fn(async (patch: UserSettings) => {
store.settings = JSON.parse(JSON.stringify(patch));
return store.settings;
}) as never;
return store;
}

async function toggleSaveAndReopen(wrapper: ReturnType<typeof mount>) {
await wrapper.find(".require-datastore-stub").trigger("click");
await wrapper.find(".preferences-update-btn button").trigger("click");
await flushPromises();

await wrapper.setProps({ preferencesVisible: false });
await wrapper.setProps({ preferencesVisible: true });
await flushPromises();
}

it("keeps the saved require_data_store value on a default node", async () => {
const store = seedStore("default", {
require_data_store: true,
autostart: { enabled: false, interval: 60 },
});

const wrapper = mount(PreferencesDialog, {
props: { preferencesVisible: true },
global: { stubs: globalStubs },
});

await toggleSaveAndReopen(wrapper);

expect(store.settings?.require_data_store).toBe(false);
expect(
wrapper.find(".require-datastore-stub").attributes("data-value"),
).toBe("false");
});

// The field is read-only on aggregator nodes, but it must still show what is
// stored rather than the aggregator-overridden effective value.
it("shows the stored require_data_store value on an aggregator node", async () => {
seedStore("aggregator", {
require_data_store: true,
autostart: { enabled: false, interval: 60 },
});

const wrapper = mount(PreferencesDialog, {
props: { preferencesVisible: false },
global: { stubs: globalStubs },
});

await wrapper.setProps({ preferencesVisible: true });
await flushPromises();

expect(
wrapper.find(".require-datastore-stub").attributes("data-value"),
).toBe("true");
});

it("still reports no datastore requirement for aggregator consumers", () => {
const store = seedStore("aggregator", {
require_data_store: true,
autostart: { enabled: false, interval: 60 },
});

expect(store.requireDataStore).toBe(false);
expect(store.requireDataStoreSetting).toBe(true);
});

it("leaves the require_data_store field editable on a default node", () => {
seedStore("default", { require_data_store: true });

const wrapper = mount(PreferencesDialog, {
props: { preferencesVisible: true },
global: { stubs: globalStubs },
});

expect(
wrapper.find(".require-datastore-stub").attributes("data-disabled"),
).toBe("false");
});

it("disables the require_data_store field on an aggregator node", () => {
seedStore("aggregator", { require_data_store: true });

const wrapper = mount(PreferencesDialog, {
props: { preferencesVisible: true },
global: { stubs: globalStubs },
});

expect(
wrapper.find(".require-datastore-stub").attributes("data-disabled"),
).toBe("true");
});

describe("when settings have not loaded yet", () => {
function seedUnloaded() {
const store = useNodeSettingsStore();
store.settings = null;
store.nodeType = "default";
store.updateSettings = vi.fn(async (patch: UserSettings) => {
store.settings = JSON.parse(JSON.stringify(patch));
return store.settings;
}) as never;
return store;
}

it("cannot be saved before the settings arrive", async () => {
seedUnloaded();

const wrapper = mount(PreferencesDialog, {
props: { preferencesVisible: true },
global: { stubs: globalStubs },
});

const button = wrapper.find(".preferences-update-btn button");
expect(button.attributes("disabled")).toBeDefined();
});

it("re-seeds the drafts when settings arrive while the dialog is open", async () => {
const store = seedUnloaded();

const wrapper = mount(PreferencesDialog, {
props: { preferencesVisible: false },
global: { stubs: globalStubs },
});
await wrapper.setProps({ preferencesVisible: true });
await flushPromises();

store.settings = {
require_data_store: false,
autostart: { enabled: true, interval: 30 },
};
await flushPromises();

expect(
wrapper.find(".require-datastore-stub").attributes("data-value"),
).toBe("false");
const autostart = wrapper.find(".autostart-stub");
expect(autostart.attributes("data-enabled")).toBe("true");
expect(autostart.attributes("data-interval")).toBe("30");
});

it("saves the loaded values, not the pre-load defaults", async () => {
const store = seedUnloaded();

const wrapper = mount(PreferencesDialog, {
props: { preferencesVisible: true },
global: { stubs: globalStubs },
});

store.settings = {
require_data_store: false,
autostart: { enabled: true, interval: 30 },
};
await flushPromises();

await wrapper.find(".preferences-update-btn button").trigger("click");
await flushPromises();

expect(store.updateSettings).toHaveBeenCalledWith({
require_data_store: false,
autostart: { enabled: true, interval: 30 },
});
});
});

it("does not discard in-progress edits when a background refetch lands", async () => {
const store = seedStore("default", {
require_data_store: true,
autostart: { enabled: false, interval: 60 },
});

const wrapper = mount(PreferencesDialog, {
props: { preferencesVisible: true },
global: { stubs: globalStubs },
});

await wrapper.find(".require-datastore-stub").trigger("click"); // user edits

// A periodic session refresh re-runs fetchSettings and replaces `settings`.
store.settings = {
require_data_store: true,
autostart: { enabled: false, interval: 60 },
};
await flushPromises();

expect(
wrapper.find(".require-datastore-stub").attributes("data-value"),
).toBe("false");
});
});
Loading