Skip to content

Commit 5cb70fc

Browse files
authored
Loading indicator improvements to audit search and filtering #3047
1 parent b39e268 commit 5cb70fc

9 files changed

Lines changed: 443 additions & 29 deletions

File tree

src/Frontend/src/components/FilterInput.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { faFilter } from "@fortawesome/free-solid-svg-icons";
66
77
const model = defineModel<string>({ required: true });
88
const emit = defineEmits<{ focus: []; blur: [] }>();
9-
const props = withDefaults(defineProps<{ placeholder?: string; ariaLabel?: string }>(), { placeholder: "Filter by name...", ariaLabel: "Filter by name" });
9+
const props = withDefaults(defineProps<{ placeholder?: string; ariaLabel?: string; disabled?: boolean }>(), { placeholder: "Filter by name...", ariaLabel: "Filter by name" });
1010
const localInput = computed({
1111
get() {
1212
return model.value;
@@ -30,7 +30,7 @@ function focus() {
3030
<template>
3131
<div role="search" aria-label="filter" class="filter-input">
3232
<FAIcon :icon="faFilter" class="icon" />
33-
<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" />
33+
<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" />
3434
</div>
3535
</template>
3636

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,29 @@
1-
<script setup lang="ts"></script>
1+
<script setup lang="ts">
2+
defineProps<{ overlay?: boolean }>();
3+
</script>
24

35
<template>
4-
<div class="text-center">
6+
<div :class="overlay ? 'loading-overlay' : 'text-center'">
57
<div class="spinner-border" role="status">
68
<span class="visually-hidden">Loading...</span>
79
</div>
810
</div>
911
</template>
12+
13+
<style scoped>
14+
.loading-overlay {
15+
position: absolute;
16+
inset: 0;
17+
z-index: 1;
18+
background: rgba(255, 255, 255, 0.9);
19+
display: flex;
20+
flex-direction: column;
21+
align-items: center;
22+
}
23+
24+
.loading-overlay .spinner-border {
25+
position: sticky;
26+
top: 4rem;
27+
margin-top: 2rem;
28+
}
29+
</style>
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, test, expect } from "vitest";
2+
import { render } from "@testing-library/vue";
3+
import { defineComponent } from "vue";
4+
import RefreshConfig from "@/components/RefreshConfig.vue";
5+
6+
/**
7+
* DSL for the RefreshConfig query-in-progress behavior.
8+
*
9+
* Tests verify that the refresh controls stay in sync with the page-level query
10+
* state owned by AuditList.
11+
*
12+
* If the loading UI changes (different button component, different attribute), only
13+
* the helpers below need updating — the tests remain unchanged.
14+
*/
15+
16+
// ==================== Stubs ====================
17+
18+
const ActionButtonStub = defineComponent({
19+
props: { loading: Boolean, disabled: Boolean },
20+
template: '<button :data-loading="String(loading)" :disabled="disabled"><slot /></button>',
21+
});
22+
23+
const ListFilterSelectorStub = defineComponent({
24+
props: { disabled: Boolean },
25+
template: '<button :data-disabled="String(disabled)" />',
26+
});
27+
28+
// ==================== DSL ====================
29+
30+
function renderRefreshConfig(queryInProgress: boolean) {
31+
const { rerender } = render(RefreshConfig, {
32+
props: { queryInProgress, modelValue: null },
33+
global: {
34+
stubs: {
35+
ActionButton: ActionButtonStub,
36+
ListFilterSelector: ListFilterSelectorStub,
37+
},
38+
},
39+
});
40+
41+
function getButton(): HTMLButtonElement {
42+
return document.querySelector("button[data-loading]") as HTMLButtonElement;
43+
}
44+
45+
function getAutoRefreshSelector(): HTMLButtonElement {
46+
return document.querySelector("button[data-disabled]") as HTMLButtonElement;
47+
}
48+
49+
async function setQueryInProgress(value: boolean) {
50+
await rerender({ queryInProgress: value, modelValue: null });
51+
}
52+
53+
return {
54+
setQueryInProgress,
55+
verify: {
56+
refreshButtonIsLoading: () => expect(getButton().dataset.loading).toBe("true"),
57+
refreshButtonIsNotLoading: () => expect(getButton().dataset.loading).toBe("false"),
58+
refreshButtonIsDisabled: () => expect(getButton()).toBeDisabled(),
59+
refreshButtonIsEnabled: () => expect(getButton()).toBeEnabled(),
60+
autoRefreshSelectorIsDisabled: () => expect(getAutoRefreshSelector().dataset.disabled).toBe("true"),
61+
autoRefreshSelectorIsEnabled: () => expect(getAutoRefreshSelector().dataset.disabled).toBe("false"),
62+
},
63+
};
64+
}
65+
66+
// ==================== Tests ====================
67+
68+
describe("FEATURE: Refresh Controls Query State", () => {
69+
describe("RULE: Refresh controls are disabled while a query is in progress", () => {
70+
test("EXAMPLE: Refresh controls reflect queryInProgress changes", async () => {
71+
const { setQueryInProgress, verify } = renderRefreshConfig(false);
72+
73+
verify.refreshButtonIsNotLoading();
74+
verify.refreshButtonIsEnabled();
75+
verify.autoRefreshSelectorIsEnabled();
76+
77+
await setQueryInProgress(true);
78+
79+
verify.refreshButtonIsLoading();
80+
verify.refreshButtonIsDisabled();
81+
verify.autoRefreshSelectorIsDisabled();
82+
83+
await setQueryInProgress(false);
84+
85+
verify.refreshButtonIsNotLoading();
86+
verify.refreshButtonIsEnabled();
87+
verify.autoRefreshSelectorIsEnabled();
88+
});
89+
});
90+
});

src/Frontend/src/components/RefreshConfig.vue

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import ListFilterSelector from "@/components/audit/ListFilterSelector.vue";
44
import ActionButton from "@/components/ActionButton.vue";
55
import { faRefresh } from "@fortawesome/free-solid-svg-icons";
66
7-
const props = defineProps<{ isLoading: boolean }>();
7+
const props = defineProps<{ queryInProgress: boolean }>();
88
const model = defineModel<number | null>({ required: true });
99
const emit = defineEmits<{ (e: "manualRefresh"): Promise<void> }>();
1010
const autoRefreshOptionsText: [number, string][] = [
@@ -43,30 +43,18 @@ watch(selectedRefresh, (newValue) => {
4343
}
4444
}
4545
});
46-
const showSpinning = ref(false);
47-
watch(
48-
() => props.isLoading,
49-
(newValue) => {
50-
if (newValue) {
51-
showSpinning.value = true;
52-
setTimeout(() => {
53-
showSpinning.value = false;
54-
}, 1000);
55-
}
56-
}
57-
);
5846
async function refresh() {
5947
await emit("manualRefresh");
6048
}
6149
</script>
6250

6351
<template>
6452
<div class="refresh-config">
65-
<ActionButton size="sm" :icon="faRefresh" :loading="showSpinning" @click="refresh">Refresh List</ActionButton>
53+
<ActionButton size="sm" :icon="faRefresh" :loading="props.queryInProgress" :disabled="props.queryInProgress" @click="refresh">Refresh List</ActionButton>
6654
<div class="filter">
6755
<div class="filter-label">Auto-Refresh:</div>
6856
<div class="filter-component">
69-
<ListFilterSelector :items="autoRefreshOptionsText.map((i) => i[1])" v-model="selectedRefresh" item-name="result" :can-clear="false" :show-clear="false" :show-filter="false" />
57+
<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" />
7058
</div>
7159
</div>
7260
</div>

0 commit comments

Comments
 (0)