143 lines
5.5 KiB
TypeScript
143 lines
5.5 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 { builtInCatalogues, createI18n, uiLabels } from "@conusai-kit/i18n"
|
|
import { createDataClient, DataProvider } from "@conusai-kit/ui/data"
|
|
import { FrameProvider, SidePanelProvider } from "@conusai-kit/ui/patterns"
|
|
import { UiLabelsProvider } from "@conusai-kit/ui/primitives"
|
|
import { renderToStaticMarkup as renderMarkup } 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, DeleteNote, deleteLabels, NotesResource, resourceLabels } 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 kitStrings = uiLabels(createI18n("en", builtInCatalogues))
|
|
const renderToStaticMarkup = (node: React.ReactNode) =>
|
|
renderMarkup(<UiLabelsProvider labels={kitStrings}>{node}</UiLabelsProvider>)
|
|
|
|
const inProvider = (node: React.ReactNode) =>
|
|
renderToStaticMarkup(
|
|
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
|
|
{node}
|
|
</DataProvider>,
|
|
)
|
|
|
|
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 notes resource", () => {
|
|
const render = (notes: readonly NoteView[], permissions: readonly string[]) =>
|
|
inProvider(
|
|
<FrameProvider
|
|
router={{
|
|
pathname: "/acme/ws/p/notes",
|
|
search: new URLSearchParams(),
|
|
refresh: async () => undefined,
|
|
navigate: () => undefined,
|
|
}}
|
|
permissions={permissions}
|
|
>
|
|
<SidePanelProvider>
|
|
<NotesResource slugs={slugs} seed={notes} labels={resourceLabels(t)} intro="Short notes" locale="en" />
|
|
</SidePanelProvider>
|
|
</FrameProvider>,
|
|
)
|
|
const writer = ["notes.note.read", "notes.note.write", "notes.note.delete"]
|
|
const html = render([note(), note({ noteId: "n2", title: "Retro" })], writer)
|
|
|
|
it("is a named table with a header for every column and one for each row", () => {
|
|
expect(html).toContain('aria-label="note"')
|
|
expect(html).toContain("Kick-off")
|
|
expect(html).toContain("Retro")
|
|
expect(html).toContain('data-guide="notes.list"')
|
|
})
|
|
|
|
it("offers a new note to whoever may write one, and only to them", () => {
|
|
expect(html).toContain("New note")
|
|
expect(html).toContain('data-guide="notes.new"')
|
|
expect(render([note()], ["notes.note.read"])).not.toContain("New note")
|
|
})
|
|
|
|
it("says so in words when there is nothing to list", () => {
|
|
expect(render([], writer)).toContain("No notes yet.")
|
|
})
|
|
})
|
|
|
|
describe("the attachments", () => {
|
|
const render = (enabled: boolean, seed: Parameters<typeof Attachments>[0]["seed"]) =>
|
|
inProvider(
|
|
<Attachments
|
|
slugs={slugs}
|
|
noteId="n1"
|
|
seed={seed}
|
|
enabled={enabled}
|
|
accept={["text/plain", "application/pdf"]}
|
|
labels={attachmentLabels(t)}
|
|
locale="en"
|
|
/>,
|
|
)
|
|
const rows = [
|
|
{
|
|
attachmentId: "a1",
|
|
fileId: "f1",
|
|
filename: "minutes.txt",
|
|
contentType: "text/plain",
|
|
size: 2048,
|
|
attachedAt: "2026-04-02T08:00:00.000Z",
|
|
},
|
|
]
|
|
|
|
it("lists each file in a captioned table and names each remove button by its file", () => {
|
|
const html = render(true, rows)
|
|
expect(html).toContain('aria-label="Attachments"')
|
|
expect(html).toContain("minutes.txt")
|
|
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(">Download minutes.txt</a>")
|
|
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).toMatch(new RegExp(`<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)} />,
|
|
)
|
|
expect(html).toContain("Delete note")
|
|
expect(html).not.toContain("Yes, delete the note")
|
|
})
|
|
})
|