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
4 changes: 2 additions & 2 deletions src/Frontend/src/components/FilterInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { faFilter } from "@fortawesome/free-solid-svg-icons";

const model = defineModel<string>({ required: true });
const emit = defineEmits<{ focus: []; blur: [] }>();
const props = withDefaults(defineProps<{ placeholder?: string; ariaLabel?: string }>(), { placeholder: "Filter by name...", ariaLabel: "Filter by name" });
const props = withDefaults(defineProps<{ placeholder?: string; ariaLabel?: string; disabled?: boolean }>(), { placeholder: "Filter by name...", ariaLabel: "Filter by name" });
const localInput = computed({
get() {
return model.value;
Expand All @@ -30,7 +30,7 @@ function focus() {
<template>
<div role="search" aria-label="filter" class="filter-input">
<FAIcon :icon="faFilter" class="icon" />
<input ref="textField" type="search" @focus="() => emit('focus')" @blur="() => emit('blur')" :placeholder="props.placeholder" :aria-label="props.ariaLabel" class="form-control filter-input" v-model="localInput" />
<input ref="textField" type="search" @focus="() => emit('focus')" @blur="() => emit('blur')" :placeholder="props.placeholder" :aria-label="props.ariaLabel" :disabled="props.disabled" class="form-control filter-input" v-model="localInput" />
</div>
</template>

Expand Down
24 changes: 22 additions & 2 deletions src/Frontend/src/components/LoadingSpinner.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
<script setup lang="ts"></script>
<script setup lang="ts">
defineProps<{ overlay?: boolean }>();
</script>

<template>
<div class="text-center">
<div :class="overlay ? 'loading-overlay' : 'text-center'">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</template>

<style scoped>
.loading-overlay {
position: absolute;
inset: 0;
z-index: 1;
background: rgba(255, 255, 255, 0.9);
display: flex;
flex-direction: column;
align-items: center;
}
.loading-overlay .spinner-border {
position: sticky;
top: 4rem;
margin-top: 2rem;
}
</style>
90 changes: 90 additions & 0 deletions src/Frontend/src/components/RefreshConfig.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, test, expect } from "vitest";
import { render } from "@testing-library/vue";
import { defineComponent } from "vue";
import RefreshConfig from "@/components/RefreshConfig.vue";

/**
* DSL for the RefreshConfig query-in-progress behavior.
*
* Tests verify that the refresh controls stay in sync with the page-level query
* state owned by AuditList.
*
* If the loading UI changes (different button component, different attribute), only
* the helpers below need updating — the tests remain unchanged.
*/

// ==================== Stubs ====================

const ActionButtonStub = defineComponent({
props: { loading: Boolean, disabled: Boolean },
template: '<button :data-loading="String(loading)" :disabled="disabled"><slot /></button>',
});

const ListFilterSelectorStub = defineComponent({
props: { disabled: Boolean },
template: '<button :data-disabled="String(disabled)" />',
});

// ==================== DSL ====================

function renderRefreshConfig(queryInProgress: boolean) {
const { rerender } = render(RefreshConfig, {
props: { queryInProgress, modelValue: null },
global: {
stubs: {
ActionButton: ActionButtonStub,
ListFilterSelector: ListFilterSelectorStub,
},
},
});

function getButton(): HTMLButtonElement {
return document.querySelector("button[data-loading]") as HTMLButtonElement;
}

function getAutoRefreshSelector(): HTMLButtonElement {
return document.querySelector("button[data-disabled]") as HTMLButtonElement;
}

async function setQueryInProgress(value: boolean) {
await rerender({ queryInProgress: value, modelValue: null });
}

return {
setQueryInProgress,
verify: {
refreshButtonIsLoading: () => expect(getButton().dataset.loading).toBe("true"),
refreshButtonIsNotLoading: () => expect(getButton().dataset.loading).toBe("false"),
refreshButtonIsDisabled: () => expect(getButton()).toBeDisabled(),
refreshButtonIsEnabled: () => expect(getButton()).toBeEnabled(),
autoRefreshSelectorIsDisabled: () => expect(getAutoRefreshSelector().dataset.disabled).toBe("true"),
autoRefreshSelectorIsEnabled: () => expect(getAutoRefreshSelector().dataset.disabled).toBe("false"),
},
};
}

// ==================== Tests ====================

describe("FEATURE: Refresh Controls Query State", () => {
describe("RULE: Refresh controls are disabled while a query is in progress", () => {
test("EXAMPLE: Refresh controls reflect queryInProgress changes", async () => {
const { setQueryInProgress, verify } = renderRefreshConfig(false);

verify.refreshButtonIsNotLoading();
verify.refreshButtonIsEnabled();
verify.autoRefreshSelectorIsEnabled();

await setQueryInProgress(true);

verify.refreshButtonIsLoading();
verify.refreshButtonIsDisabled();
verify.autoRefreshSelectorIsDisabled();

await setQueryInProgress(false);

verify.refreshButtonIsNotLoading();
verify.refreshButtonIsEnabled();
verify.autoRefreshSelectorIsEnabled();
});
});
});
18 changes: 3 additions & 15 deletions src/Frontend/src/components/RefreshConfig.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import ListFilterSelector from "@/components/audit/ListFilterSelector.vue";
import ActionButton from "@/components/ActionButton.vue";
import { faRefresh } from "@fortawesome/free-solid-svg-icons";
const props = defineProps<{ isLoading: boolean }>();
const props = defineProps<{ queryInProgress: boolean }>();
const model = defineModel<number | null>({ required: true });
const emit = defineEmits<{ (e: "manualRefresh"): Promise<void> }>();
const autoRefreshOptionsText: [number, string][] = [
Expand Down Expand Up @@ -43,30 +43,18 @@ watch(selectedRefresh, (newValue) => {
}
}
});
const showSpinning = ref(false);
watch(
() => props.isLoading,
(newValue) => {
if (newValue) {
showSpinning.value = true;
setTimeout(() => {
showSpinning.value = false;
}, 1000);
}
}
);
async function refresh() {
await emit("manualRefresh");
}
</script>

<template>
<div class="refresh-config">
<ActionButton size="sm" :icon="faRefresh" :loading="showSpinning" @click="refresh">Refresh List</ActionButton>
<ActionButton size="sm" :icon="faRefresh" :loading="props.queryInProgress" :disabled="props.queryInProgress" @click="refresh">Refresh List</ActionButton>
<div class="filter">
<div class="filter-label">Auto-Refresh:</div>
<div class="filter-component">
<ListFilterSelector :items="autoRefreshOptionsText.map((i) => i[1])" v-model="selectedRefresh" item-name="result" :can-clear="false" :show-clear="false" :show-filter="false" />
<ListFilterSelector :items="autoRefreshOptionsText.map((i) => i[1])" v-model="selectedRefresh" item-name="result" :can-clear="false" :show-clear="false" :show-filter="false" :disabled="props.queryInProgress" />
</div>
</div>
</div>
Expand Down
Loading