feat(notes): show loading and failed states for stacked notes
All checks were successful
CI / verify (push) Successful in 1m3s

A note that couldn't load rendered a silent blank div. Add a calm
NoteState placeholder (spinner while loading, message + retry on
failure) and gate the read view on it; content still wins when present.
This commit is contained in:
Julien Calixte
2026-06-29 00:31:09 +02:00
parent 8f50259efe
commit 08d2d804ff
3 changed files with 107 additions and 8 deletions

View File

@@ -0,0 +1,29 @@
import { mount } from "@vue/test-utils"
import { describe, expect, it } from "vitest"
import NoteState from "./NoteState.vue"
describe("NoteState", () => {
it("shows a spinner while loading and no retry", () => {
const wrapper = mount(NoteState, { props: { status: "loading" } })
expect(wrapper.find(".loading-spinner").exists()).toBe(true)
expect(wrapper.find(".note-state-retry").exists()).toBe(false)
})
it("shows a message and retry button when failed", () => {
const wrapper = mount(NoteState, { props: { status: "failed" } })
expect(wrapper.find(".loading-spinner").exists()).toBe(false)
expect(wrapper.text()).toContain("Couldn't load this note")
expect(wrapper.find(".note-state-retry").exists()).toBe(true)
})
it("emits retry when the retry button is clicked", async () => {
const wrapper = mount(NoteState, { props: { status: "failed" } })
await wrapper.find(".note-state-retry").trigger("click")
expect(wrapper.emitted("retry")).toHaveLength(1)
})
})

View File

@@ -0,0 +1,48 @@
<script lang="ts" setup>
defineProps<{ status: "loading" | "failed" }>()
defineEmits<{ retry: [] }>()
</script>
<template>
<div class="note-state">
<span
v-if="status === 'loading'"
class="loading loading-spinner loading-md"
aria-label="Loading note"
></span>
<template v-else>
<p class="note-state-message">Couldn't load this note.</p>
<button type="button" class="note-state-retry" @click="$emit('retry')">
Retry
</button>
</template>
</div>
</template>
<style scoped lang="scss">
.note-state {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem 1rem;
color: var(--color-base-content);
opacity: 0.55;
text-align: center;
}
.note-state-message {
margin: 0;
}
.note-state-retry {
padding: 0.25rem 0.85rem;
border: 1px solid currentColor;
border-radius: 0.375rem;
background: transparent;
color: inherit;
cursor: pointer;
}
</style>

View File

@@ -40,6 +40,10 @@ const EditNote = defineAsyncComponent(
() => import("@/modules/note/components/EditNote.vue")
)
const NoteState = defineAsyncComponent(
() => import("@/components/NoteState.vue")
)
const props = defineProps<{
user: string
repo: string
@@ -154,10 +158,21 @@ const {
})
const conflictOpen = ref(false)
const loadStatus = ref<"loading" | "ready" | "failed">("loading")
onMounted(async () => {
initialRawContent.value = await getRawContent()
})
const loadNote = async () => {
loadStatus.value = "loading"
const raw = await getRawContent()
if (raw === null) {
loadStatus.value = "failed"
return
}
rawContent.value = raw
initialRawContent.value = raw
loadStatus.value = "ready"
}
onMounted(loadNote)
watch(
path,
@@ -412,11 +427,18 @@ const onBadgeClick = async () => {
<div v-if="mode === 'edit' && isMarkdown" class="edit">
<edit-note :key="editKey" v-model="rawContent" />
</div>
<div
v-if="mode === 'read'"
class="note-content"
v-html="displayedContent"
></div>
<template v-else-if="mode === 'read'">
<div
v-if="displayedContent"
class="note-content"
v-html="displayedContent"
></div>
<note-state
v-else
:status="loadStatus === 'failed' ? 'failed' : 'loading'"
@retry="loadNote"
/>
</template>
</section>
<linked-notes v-if="hasBacklinks && content" :sha="sha" />
<note-conflict-modal