169 lines
5.9 KiB
TypeScript
169 lines
5.9 KiB
TypeScript
// The components' markup, rendered on the server the way a page renders it: what a screen reader and
|
|
// a keyboard user get is in the HTML, so the accessibility basics are asserted there -- a name for
|
|
// every control, a caption and scoped headers for every table, a status that says what a colour would.
|
|
|
|
import { buildRegistry } from "@conusai-kit/core"
|
|
import { createI18n } from "@conusai-kit/i18n"
|
|
import { TransportProvider } from "@conusai-kit/ui"
|
|
import { renderToStaticMarkup } from "react-dom/server"
|
|
import { describe, expect, it } from "vitest"
|
|
import { appModules } from "../../index.js"
|
|
import type { NoteView } from "../contract/index.js"
|
|
import {
|
|
Attachments,
|
|
attachmentLabels,
|
|
createFormLabels,
|
|
DeleteNote,
|
|
deleteLabels,
|
|
editFormLabels,
|
|
listLabels,
|
|
NoteForm,
|
|
NoteList,
|
|
sizeLabel,
|
|
} from "../ui/index.js"
|
|
|
|
const catalogues = buildRegistry([...appModules]).catalogues
|
|
const i18n = createI18n("en", { en: { notes: catalogues.en?.notes ?? {} } })
|
|
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`notes.${key}`, values)
|
|
const slugs = { org: "acme", workspace: "ws", project: "p" }
|
|
const failed = "That did not work: {detail}"
|
|
|
|
const inProvider = (node: React.ReactNode) =>
|
|
renderToStaticMarkup(
|
|
<TransportProvider call={async () => undefined} describe={() => "x"}>
|
|
{node}
|
|
</TransportProvider>,
|
|
)
|
|
|
|
const note = (over: Partial<NoteView> = {}): NoteView => ({
|
|
noteId: "n1",
|
|
title: "Kick-off",
|
|
body: "Agenda",
|
|
version: 3,
|
|
createdBy: "u1",
|
|
createdAt: "2026-04-01T08:00:00.000Z",
|
|
updatedAt: "2026-04-02T08:00:00.000Z",
|
|
attachmentCount: 2,
|
|
...over,
|
|
})
|
|
|
|
describe("the note list", () => {
|
|
const html = renderToStaticMarkup(
|
|
<NoteList
|
|
notes={[note(), note({ noteId: "n2", title: "Retro" })]}
|
|
hrefFor={(id) => `/acme/ws/p/notes/${id}`}
|
|
labels={listLabels(t)}
|
|
formatDate={(iso) => iso.slice(0, 10)}
|
|
/>,
|
|
)
|
|
|
|
it("is a table with a caption and a scoped header for every column", () => {
|
|
expect(html).toContain("<caption>Notes</caption>")
|
|
expect(html.match(/<th scope="col">/g)).toHaveLength(3)
|
|
expect(html).toContain('<th scope="row">')
|
|
})
|
|
|
|
it("names each link by its note, so a list of links is not a list of the same word", () => {
|
|
expect(html).toContain('aria-label="Open Kick-off"')
|
|
expect(html).toContain('aria-label="Open Retro"')
|
|
expect(html).toContain('href="/acme/ws/p/notes/n1"')
|
|
})
|
|
|
|
it("says so in words when there is nothing to list", () => {
|
|
const empty = renderToStaticMarkup(
|
|
<NoteList notes={[]} hrefFor={() => ""} labels={listLabels(t)} formatDate={() => ""} />,
|
|
)
|
|
expect(empty).toBe('<p data-guide="notes.note-list">No notes yet.</p>')
|
|
})
|
|
})
|
|
|
|
describe("the note form", () => {
|
|
const html = inProvider(
|
|
<NoteForm
|
|
slugs={slugs}
|
|
target={{ mode: "create", listHref: "/acme/ws/p/notes" }}
|
|
labels={createFormLabels(t, failed)}
|
|
/>,
|
|
)
|
|
|
|
it("labels every field", () => {
|
|
for (const name of ["title", "body"]) {
|
|
const id = new RegExp(`<(?:input|textarea) [^>]*\\bid="([^"]+)"[^>]*\\bname="${name}"`).exec(html)?.[1]
|
|
expect(id, name).toBeTruthy()
|
|
expect(html).toMatch(new RegExp(`<label for="${id}">`))
|
|
}
|
|
})
|
|
|
|
it("starts empty to create, and carries the note's words to edit", () => {
|
|
expect(html).toContain("New note")
|
|
const edit = inProvider(
|
|
<NoteForm
|
|
slugs={slugs}
|
|
target={{ mode: "edit", noteId: "n1", version: 3, title: "Kick-off", body: "Agenda" }}
|
|
labels={editFormLabels(t, failed)}
|
|
/>,
|
|
)
|
|
expect(edit).toContain("Edit note")
|
|
expect(edit).toContain('value="Kick-off"')
|
|
expect(edit).toContain(">Agenda</textarea>")
|
|
})
|
|
|
|
it("announces its outcome in a status region", () => {
|
|
expect(html).toContain('<p role="status">')
|
|
})
|
|
})
|
|
|
|
describe("the attachments", () => {
|
|
const render = (enabled: boolean, rows: Parameters<typeof Attachments>[0]["rows"]) =>
|
|
inProvider(
|
|
<Attachments
|
|
slugs={slugs}
|
|
noteId="n1"
|
|
rows={rows}
|
|
enabled={enabled}
|
|
accept={["text/plain", "application/pdf"]}
|
|
labels={attachmentLabels(t, failed, rows.length)}
|
|
/>,
|
|
)
|
|
const rows = [
|
|
{ attachmentId: "a1", fileId: "f1", filename: "minutes.txt", contentType: "text/plain", size: sizeLabel(t, 2048) },
|
|
]
|
|
|
|
it("lists each file in a captioned table and names each remove button by its file", () => {
|
|
const html = render(true, rows)
|
|
expect(html).toContain("<caption>Attachments</caption>")
|
|
expect(html).toContain('<th scope="row">minutes.txt</th>')
|
|
expect(html).toContain("2 KB")
|
|
expect(html).toContain('aria-label="Remove minutes.txt"')
|
|
expect(html).toContain('href="/api/v1/files.file.download?org=acme&workspace=ws&project=p&fileId=f1"')
|
|
expect(html).toContain('aria-label="Download minutes.txt"')
|
|
expect(html).toContain("1 attachment")
|
|
})
|
|
|
|
it("labels its file input, describes it with the hint, and asks for the types it accepts", () => {
|
|
const html = render(true, [])
|
|
const id = /<input [^>]*\bid="([^"]+)"[^>]*\bname="file"/.exec(html)?.[1]
|
|
expect(id).toBeTruthy()
|
|
expect(html).toContain(`<label for="${id}">Attach a file</label>`)
|
|
expect(html).toContain("aria-describedby=")
|
|
expect(html).toContain('accept="text/plain,application/pdf"')
|
|
expect(html).toContain("Nothing is attached.")
|
|
})
|
|
|
|
it("offers no upload when the feature flag is off, and says why", () => {
|
|
const html = render(false, [])
|
|
expect(html).not.toContain('type="file"')
|
|
expect(html).toContain("Attachments are switched off for this project.")
|
|
})
|
|
})
|
|
|
|
describe("the delete button", () => {
|
|
it("asks first: the destructive button is not on the page until the person has said yes to a question", () => {
|
|
const html = inProvider(
|
|
<DeleteNote slugs={slugs} noteId="n1" listHref="/acme/ws/p/notes" labels={deleteLabels(t, failed)} />,
|
|
)
|
|
expect(html).toContain("Delete note")
|
|
expect(html).not.toContain("Yes, delete the note")
|
|
})
|
|
})
|