feat(housekeeping): add dedicated polls workflow

This commit is contained in:
Simo committed 2026-09-02 20:06:26 +02:00
1 parent 5edaa0254c
commit 51eecd0e72
8 files changed
+1382 -155

No files matched your search

@@ -15,7 +15,10 @@ import {
} from "./content-command-form";
import { parseContentListInput } from "./content-page-frame";
import { ContentEditorialPage } from "./editorial";
import { ContentEngagementPage } from "./engagement";
import {
ContentEngagementPage,
renderContentEngagementPage,
} from "./engagement";
import { ContentHelpPage } from "./help";
import { ContentLocalizationPage } from "./localization";
import { ContentMediaPage } from "./media";
@@ -572,40 +575,26 @@ describe("Content actionable form wiring", () => {
},
);
it("renders poll creation with show-results checked by default", () => {
const html = renderToStaticMarkup(
<ContentEngagementPage
context={context([PERMS.POLLS_EDIT])}
result={ok(
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
"poll-create-checkbox",
)}
routeId="content.engagement.poll-create"
/>,
);
expect(html).toMatch(/name="showResults"[^>]*checked=""/u);
const multipleChoice = html.match(
/<input[^>]*name="multipleChoice"[^>]*>/u,
)?.[0];
expect(multipleChoice).toBeDefined();
expect(multipleChoice).not.toContain("checked");
});
it("renders poll updates with explicit unchanged, enabled, and disabled choices", () => {
const html = renderToStaticMarkup(
<ContentEngagementPage
context={context([PERMS.POLLS_EDIT])}
result={ok(
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
"poll-update-checkbox",
)}
routeId="content.engagement.poll-detail"
/>,
);
expect(html).toMatch(/<select[^>]*name="showResults"/u);
expect(html).toContain("No change");
expect(html).toContain('value="true"');
expect(html).toContain('value="false"');
it("routes Poll inputs through the dedicated workflow with bounded response state", () => {
expect(
parseContentListInput({
search: "summer",
status: "ACTIVE",
pageSize: "500",
offset: "-2",
responseQuestionId: "22",
responsePageSize: "500",
responseOffset: "50",
}),
).toEqual({
search: "summer",
status: "active",
pageSize: 100,
offset: 0,
responseQuestionId: "22",
responsePageSize: 50,
responseOffset: 50,
});
});
it("renders mutation forms only with the exact capability", () => {
@@ -750,7 +739,7 @@ describe("Content actionable form wiring", () => {
);
});
it("renders validator-shaped prize, question, and prefix inputs", () => {
it("renders validator-shaped prize and prefix inputs", () => {
const eventHtml = renderToStaticMarkup(
<ContentEngagementPage
context={context([PERMS.EVENTS_EDIT])}
@@ -780,18 +769,6 @@ describe("Content actionable form wiring", () => {
}
expect(eventHtml).not.toContain('name="prize"');
const pollHtml = renderToStaticMarkup(
<ContentEngagementPage
context={context([PERMS.POLLS_EDIT])}
result={ok(
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
"poll-question-form",
)}
routeId="content.engagement.poll-detail"
/>,
);
expect(pollHtml).toContain('name="options"');
const prefixHtml = renderToStaticMarkup(
<ContentEngagementPage
context={context([PERMS.PREFIXES_EDIT])}
@@ -805,6 +782,86 @@ describe("Content actionable form wiring", () => {
expect(prefixHtml).toContain('name="color"');
});
it("forwards every parsed Poll result selector to query and rendering", async () => {
const contentQueries = await import("../queries/content-queries");
const run = vi.spyOn(contentQueries.contentQuery, "run").mockResolvedValue(
ok(
{
kind: "engagement",
items: [
{
id: "17",
title: "Summer survey",
status: "active",
privatePayload: {
kind: "poll-detail",
description: "Choose the next hotel theme.",
showResults: true,
multipleChoice: false,
startsAt: null,
endsAt: null,
questionCount: 1,
voterCount: 1,
answerCount: 1,
questions: [
{
id: "22",
question: "What should improve?",
type: "text",
sortOrder: 0,
options: [],
answerCount: 1,
choiceResults: [],
},
],
textResponses: {
questionId: "22",
items: [],
total: 101,
pageSize: 50,
offset: 50,
},
},
},
],
total: 1,
partialDependencies: [],
},
"poll-render",
),
);
const page = await renderContentEngagementPage({
context: context([PERMS.POLLS_VIEW]),
match: {
routeId: "content.engagement.poll-detail",
domain: "content",
params: { id: "17" },
canonicalHref: "/ase-next/content/engagement/polls/17",
},
searchParams: {
responseQuestionId: "22",
responsePageSize: "50",
responseOffset: "50",
},
});
const html = renderToStaticMarkup(page);
expect(run).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
list: expect.objectContaining({
responseQuestionId: "22",
responsePageSize: 50,
responseOffset: 50,
}),
}),
);
expect(html).toContain("responseQuestionId=22");
expect(html).toContain("responsePageSize=50");
expect(html).toContain("responseOffset=100");
run.mockRestore();
});
it("matches emulator setting fields to the database column bounds", () => {
const html = renderToStaticMarkup(
<ContentLocalizationPage
@@ -2,21 +2,22 @@ import { PERMS } from "@/lib/permission-slugs";
import { resolveHousekeepingRouteTitle } from "../../../foundation/page/route-copy";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries";
import {
type ContentCommandField,
ContentCommandForm,
} from "./content-command-form";
import { ContentCommandForm } from "./content-command-form";
import {
ContentPageFrame,
type ContentPageProps,
parseContentListInput,
} from "./content-page-frame";
import { ContentEventWorkflow } from "./event-workflow";
import { ContentPollWorkflow } from "./poll-workflow";
interface ContentEngagementPageProps extends ContentPageProps {
readonly status?: string;
readonly pageSize?: number;
readonly offset?: number;
readonly responseQuestionId?: string;
readonly responsePageSize?: number;
readonly responseOffset?: number;
}
export function ContentEngagementPage({
@@ -25,6 +26,9 @@ export function ContentEngagementPage({
status = "",
pageSize = 25,
offset = 0,
responseQuestionId = "",
responsePageSize = 25,
responseOffset = 0,
context,
result,
routeId,
@@ -45,55 +49,30 @@ export function ContentEngagementPage({
/>
);
}
const canPolls = context.has(PERMS.POLLS_EDIT);
if (
routeId === "content.engagement.polls" ||
routeId === "content.engagement.poll-create" ||
routeId === "content.engagement.poll-detail"
) {
return (
<ContentPollWorkflow
title={title ?? "Polls"}
context={context}
result={result}
routeId={routeId}
list={{
search: search ?? "",
status,
pageSize,
offset,
responseQuestionId,
responsePageSize,
responseOffset,
}}
/>
);
}
const canPrefixes = context.has(PERMS.PREFIXES_EDIT);
const creatingPoll = routeId === "content.engagement.poll-create";
const pollFields: readonly ContentCommandField[] = [
{
name: "id",
label: "Poll ID",
type: "identifier",
required: !creatingPoll,
},
{
name: "title",
label: "Title",
type: "text",
required: creatingPoll,
maxLength: 255,
},
{
name: "description",
label: "Description",
type: "textarea",
maxLength: 2_000,
},
{
name: "status",
label: "Status",
type: "select",
options: [
{ value: "draft", label: "Draft" },
{ value: "active", label: "Active" },
{ value: "closed", label: "Closed" },
],
},
{
name: "showResults",
label: "Show results",
type: "checkbox",
defaultValue: creatingPoll,
allowUnchanged: !creatingPoll,
},
{
name: "multipleChoice",
label: "Allow multiple choices",
type: "checkbox",
allowUnchanged: !creatingPoll,
},
{ name: "startsAt", label: "Starts at", type: "text", maxLength: 50 },
{ name: "endsAt", label: "Ends at", type: "text", maxLength: 50 },
];
return (
<ContentPageFrame
title={title ?? "Engagement"}
@@ -101,60 +80,6 @@ export function ContentEngagementPage({
result={result}
forms={
<div className="grid gap-3 lg:grid-cols-2">
{canPolls && routeId?.includes("poll") ? (
<>
<ContentCommandForm
commandId="content.engagement.poll.change"
buttonLabel="Save poll"
input={{ action: creatingPoll ? "create" : "update" }}
fields={pollFields}
/>
<ContentCommandForm
commandId="content.engagement.poll-question.change"
buttonLabel="Save question"
input={{ action: "create" }}
fields={[
{
name: "pollId",
label: "Poll ID",
type: "identifier",
required: true,
},
{
name: "question",
label: "Question",
type: "text",
required: true,
maxLength: 500,
},
{
name: "type",
label: "Question type",
type: "select",
options: [
{ value: "single", label: "Single choice" },
{ value: "multiple", label: "Multiple choice" },
{ value: "text", label: "Free text" },
],
},
{
name: "sortOrder",
label: "Sort order",
type: "number",
min: 0,
defaultValue: 0,
},
{
name: "options",
label: "Options",
type: "textarea",
required: true,
maxLength: 20_000,
},
]}
/>
</>
) : null}
{canPrefixes && routeId === "content.engagement.prefixes" ? (
<>
<ContentCommandForm
@@ -254,6 +179,9 @@ export async function renderContentEngagementPage(
status={list.status}
pageSize={list.pageSize}
offset={list.offset}
responseQuestionId={list.responseQuestionId}
responsePageSize={list.responsePageSize}
responseOffset={list.responseOffset}
/>
);
}
@@ -0,0 +1,135 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { ContentPollDetailPayload } from "../queries/content-queries";
import { ContentPollResults } from "./poll-results";
const detail: ContentPollDetailPayload = {
kind: "poll-detail",
description: "Choose the next hotel theme.",
showResults: true,
multipleChoice: true,
startsAt: "2026-09-01T18:00:00.000Z",
endsAt: "2026-09-05T18:00:00.000Z",
questionCount: 4,
voterCount: 3,
answerCount: 8,
questions: [
{
id: "21",
question: "Pick a colour",
type: "single",
sortOrder: 0,
options: ["Red", "Blue"],
answerCount: 3,
choiceResults: [
{ option: "Red", count: 3 },
{ option: "Blue", count: 0 },
],
},
{
id: "23",
question: "Select improvements",
type: "multiple",
sortOrder: 1,
options: ["Events", "Furniture"],
answerCount: 3,
choiceResults: [
{ option: "Events", count: 2 },
{ option: "Furniture", count: 2 },
],
},
{
id: "22",
question: "What should improve?",
type: "text",
sortOrder: 2,
options: [],
answerCount: 2,
choiceResults: [],
},
{
id: "24",
question: "Anything else?",
type: "text",
sortOrder: 3,
options: [],
answerCount: 0,
choiceResults: [],
},
],
textResponses: {
questionId: "22",
items: [
{
id: "31",
questionId: "22",
userId: "9",
username: "Alice",
answer: "Better prizes",
createdAt: "2026-09-02T20:00:00.000Z",
},
{
id: "32",
questionId: "22",
userId: "10",
username: null,
answer: "More competitions",
createdAt: "2026-09-02T20:05:00.000Z",
},
],
total: 80,
pageSize: 25,
offset: 25,
},
};
describe("Content Poll results", () => {
it("renders choice totals using respondent answers as the percentage denominator", () => {
const html = renderToStaticMarkup(
<ContentPollResults
pollId="17"
detail={detail}
list={{
responseQuestionId: "22",
responsePageSize: 25,
responseOffset: 25,
}}
/>,
);
expect(html).toContain("Red");
expect(html).toContain("3 votes");
expect(html).toContain("100%");
expect(html).toContain("Events");
expect(html).toContain("2 votes");
expect(html).toContain("67%");
});
it("renders scoped text selectors, immutable identities, UTC times, and bounded pages", () => {
const html = renderToStaticMarkup(
<ContentPollResults
pollId="17"
detail={detail}
list={{
responseQuestionId: "22",
responsePageSize: 25,
responseOffset: 25,
}}
/>,
);
expect(html).toContain("What should improve?");
expect(html).toContain("Anything else?");
expect(html).toContain("Alice");
expect(html).toContain("user 9");
expect(html).toContain("Unavailable user");
expect(html).toContain("user 10");
expect(html).toContain("Better prizes");
expect(html).toContain('dateTime="2026-09-02T20:00:00.000Z"');
expect(html).toContain("/ase-next/content/engagement/polls/17?");
expect(html).toContain("responseQuestionId=22");
expect(html).toContain("responsePageSize=25");
expect(html).toContain("responseOffset=0");
expect(html).toContain("responseOffset=50");
});
});
@@ -0,0 +1,205 @@
import type { ContentPollDetailPayload } from "../queries/content-queries";
import type { ContentPollListState } from "./poll-workflow";
export interface ContentPollResultsProps {
readonly pollId: string;
readonly detail: ContentPollDetailPayload;
readonly list: Pick<
ContentPollListState,
"responseQuestionId" | "responsePageSize" | "responseOffset"
>;
}
function displayDate(value: string): string {
return new Intl.DateTimeFormat("en-GB", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "UTC",
}).format(new Date(value));
}
function responseHref(
pollId: string,
questionId: string,
pageSize: number,
offset: number,
): string {
const params = new URLSearchParams();
params.set("responseQuestionId", questionId);
params.set("responsePageSize", String(pageSize));
params.set("responseOffset", String(Math.max(0, offset)));
return `/ase-next/content/engagement/polls/${encodeURIComponent(pollId)}?${params.toString()}`;
}
function voteLabel(count: number): string {
return `${count} ${count === 1 ? "vote" : "votes"}`;
}
export function ContentPollResults({
pollId,
detail,
list,
}: ContentPollResultsProps) {
const choiceQuestions = detail.questions.filter(
(question) => question.type !== "text",
);
const textQuestions = detail.questions.filter(
(question) => question.type === "text",
);
const selectedQuestionId = detail.textResponses.questionId;
const previousOffset = Math.max(
0,
list.responseOffset - list.responsePageSize,
);
const nextOffset = list.responseOffset + list.responsePageSize;
return (
<section className="space-y-5" aria-labelledby="poll-results-heading">
<h2 id="poll-results-heading">Results</h2>
{choiceQuestions.length === 0 ? (
<p>No choice results are available.</p>
) : (
<div className="grid gap-4 lg:grid-cols-2">
{choiceQuestions.map((question) => (
<section
key={question.id}
className="space-y-3 rounded border border-[var(--admin-border)] p-4"
>
<h3>{question.question}</h3>
<p className="text-sm text-[var(--admin-text-muted)]">
{voteLabel(question.answerCount)} received
</p>
<ul className="space-y-2">
{question.choiceResults.map((result) => {
const percentage = Math.round(
(result.count / Math.max(1, question.answerCount)) * 100,
);
return (
<li key={result.option}>
<div className="flex justify-between gap-3">
<span>{result.option}</span>
<span>
{voteLabel(result.count)} · {percentage}%
</span>
</div>
<progress
value={percentage}
max={100}
aria-label={`${result.option}: ${percentage}%`}
/>
</li>
);
})}
</ul>
</section>
))}
</div>
)}
<section className="space-y-3" aria-labelledby="text-results-heading">
<h3 id="text-results-heading">Free-text responses</h3>
{textQuestions.length === 0 ? (
<p>No free-text questions are configured.</p>
) : (
<>
<nav aria-label="Free-text questions">
<ul className="flex flex-wrap gap-2">
{textQuestions.map((question) => (
<li key={question.id}>
<a
href={responseHref(
pollId,
question.id,
list.responsePageSize,
0,
)}
aria-current={
question.id === selectedQuestionId ? "page" : undefined
}
>
{question.question}
</a>
</li>
))}
</ul>
</nav>
{detail.textResponses.items.length === 0 ? (
<p>No responses for this question.</p>
) : (
<div className="overflow-x-auto">
<table>
<caption className="sr-only">
Free-text Poll responses
</caption>
<thead>
<tr>
<th scope="col">User</th>
<th scope="col">Answer</th>
<th scope="col">Submitted</th>
</tr>
</thead>
<tbody>
{detail.textResponses.items.map((response) => (
<tr key={response.id}>
<td>
{response.username ?? "Unavailable user"} (user{" "}
{response.userId})
</td>
<td className="whitespace-pre-wrap">
{response.answer}
</td>
<td>
<time dateTime={response.createdAt}>
{displayDate(response.createdAt)} UTC
</time>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{selectedQuestionId &&
(list.responseOffset > 0 ||
nextOffset < detail.textResponses.total) ? (
<nav
aria-label="Free-text response pages"
className="flex justify-between"
>
{list.responseOffset > 0 ? (
<a
href={responseHref(
pollId,
selectedQuestionId,
list.responsePageSize,
previousOffset,
)}
>
Previous
</a>
) : (
<span />
)}
{nextOffset < detail.textResponses.total ? (
<a
href={responseHref(
pollId,
selectedQuestionId,
list.responsePageSize,
nextOffset,
)}
>
Next
</a>
) : null}
</nav>
) : null}
</>
)}
</section>
</section>
);
}
@@ -0,0 +1,308 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import {
fail,
type HousekeepingCapabilityContext,
ok,
} from "../../../foundation/contracts";
import type {
ContentPollDetailPayload,
ContentQueryData,
} from "../queries/content-queries";
import { ContentPollWorkflow } from "./poll-workflow";
function context(granted: readonly string[]): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: (slug) => permissions.has(slug),
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
};
}
function data(
items: ContentQueryData["items"],
total = items.length,
): ContentQueryData {
return { kind: "engagement", items, total, partialDependencies: [] };
}
const list = {
search: "summer",
status: "active",
pageSize: 25,
offset: 25,
responseQuestionId: "22",
responsePageSize: 25,
responseOffset: 0,
} as const;
const detail: ContentPollDetailPayload = {
kind: "poll-detail",
description: "Choose the next hotel theme.",
showResults: true,
multipleChoice: false,
startsAt: "2026-09-02T18:00:00.000Z",
endsAt: "2026-09-03T18:00:00.000Z",
questionCount: 2,
voterCount: 7,
answerCount: 9,
questions: [
{
id: "21",
question: "Pick a colour",
type: "single",
sortOrder: 0,
options: ["Red", "Blue"],
answerCount: 7,
choiceResults: [
{ option: "Red", count: 5 },
{ option: "Blue", count: 2 },
],
},
{
id: "22",
question: "What should improve?",
type: "text",
sortOrder: 1,
options: [],
answerCount: 2,
choiceResults: [],
},
],
textResponses: {
questionId: "22",
items: [
{
id: "31",
questionId: "22",
userId: "9",
username: "Alice",
answer: "Better prizes",
createdAt: "2026-09-02T20:00:00.000Z",
},
],
total: 1,
pageSize: 25,
offset: 0,
},
};
const pollItem = {
id: "17",
title: "Summer survey",
status: "active",
updatedAt: "2026-09-02T21:00:00.000Z",
privatePayload: detail,
};
const viewer = context([PERMS.POLLS_VIEW]);
const editor = context([PERMS.POLLS_VIEW, PERMS.POLLS_EDIT]);
function renderList(
ctx: HousekeepingCapabilityContext,
result?: Parameters<typeof ContentPollWorkflow>[0]["result"],
): string {
return renderToStaticMarkup(
<ContentPollWorkflow
title="Polls"
context={ctx}
result={result}
routeId="content.engagement.polls"
list={list}
/>,
);
}
describe("Content Poll workflow", () => {
it("renders explicit loading, forbidden, dependency-error, empty, and malformed states", () => {
expect(renderList(viewer)).toContain('data-housekeeping-state="loading"');
expect(
renderList(
viewer,
fail("FORBIDDEN", "errors.housekeeping.forbidden", "forbidden"),
),
).toContain('data-housekeeping-state="forbidden"');
expect(
renderList(
viewer,
fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
"dependency",
),
),
).toContain('data-housekeeping-state="error"');
expect(renderList(viewer, ok(data([]), "empty"))).toContain(
'data-housekeeping-state="empty"',
);
expect(
renderList(
viewer,
ok(data([{ id: "17", title: "Broken", privatePayload: {} }]), "bad"),
),
).toContain('data-housekeeping-state="error"');
});
it("renders a filterable operational list and limits creation to editors", () => {
const result = ok(
data(
[
{
id: "17",
title: "Summer survey",
status: "active",
href: "/ase-next/content/engagement/polls/17",
privatePayload: {
kind: "poll-summary",
showResults: true,
multipleChoice: false,
startsAt: "2026-09-02T18:00:00.000Z",
endsAt: "2026-09-03T18:00:00.000Z",
questionCount: 3,
voterCount: 7,
answerCount: 9,
},
},
],
80,
),
"list",
);
const editorHtml = renderList(editor, result);
const viewerHtml = renderList(viewer, result);
expect(editorHtml).toContain('data-housekeeping-state="ready"');
expect(editorHtml).toContain("/ase-next/content/engagement/polls/create");
expect(editorHtml).toContain("New poll");
expect(editorHtml).toContain('name="search"');
expect(editorHtml).toContain('value="summer"');
expect(editorHtml).toContain('name="status"');
expect(editorHtml).toMatch(/value="active"[^>]*selected=""/u);
expect(editorHtml).toContain("Summer survey");
expect(editorHtml).toContain('dateTime="2026-09-02T18:00:00.000Z"');
expect(editorHtml).toContain('dateTime="2026-09-03T18:00:00.000Z"');
expect(editorHtml).toContain("3 questions");
expect(editorHtml).toContain("7 voters");
expect(editorHtml).toContain("9 answers");
expect(editorHtml).toContain("Showing 1 of 80 polls");
expect(editorHtml).toContain("offset=0");
expect(editorHtml).toContain("offset=50");
expect(viewerHtml).not.toContain("New poll");
expect(viewerHtml).not.toContain("data-housekeeping-command");
});
it("renders creation with UTC-native fields and no manual identifiers", () => {
const html = renderToStaticMarkup(
<ContentPollWorkflow
title="Create poll"
context={editor}
result={ok(
data([
{
id: "create",
title: "Create poll",
privatePayload: { kind: "poll-create" },
},
]),
"create",
)}
routeId="content.engagement.poll-create"
list={list}
/>,
);
expect(html).toContain(
'data-housekeeping-command="content.engagement.poll.change"',
);
for (const name of [
"title",
"description",
"status",
"showResults",
"multipleChoice",
"startsAt",
"endsAt",
]) {
expect(html).toContain(`name="${name}"`);
}
expect(html.match(/type="datetime-local"/gu)).toHaveLength(2);
expect(html).toContain("UTC");
expect(html).toContain("without timezone conversion");
expect(html).not.toContain('name="id"');
expect(html).not.toContain('name="pollId"');
});
it("shows detail data and results to viewers without mutation forms", () => {
const html = renderToStaticMarkup(
<ContentPollWorkflow
title="Poll detail"
context={viewer}
result={ok(data([pollItem]), "detail-view")}
routeId="content.engagement.poll-detail"
list={list}
/>,
);
expect(html).toContain("Overview");
expect(html).toContain("Questions");
expect(html).toContain("Results");
expect(html).toContain("Choose the next hotel theme.");
expect(html).toContain("Pick a colour");
expect(html).toContain("Better prizes");
expect(html).not.toContain("data-housekeeping-command");
expect(html).not.toContain("Danger zone");
});
it("binds editor operations to the detail entities without manual ID fields", () => {
const html = renderToStaticMarkup(
<ContentPollWorkflow
title="Poll detail"
context={editor}
result={ok(data([pollItem]), "detail-edit")}
routeId="content.engagement.poll-detail"
list={list}
/>,
);
expect(html).toContain('name="title" value="Summer survey"');
expect(html).toContain("Choose the next hotel theme.");
expect(html).toMatch(/value="active"[^>]*selected=""/u);
expect(html).toContain('name="startsAt"');
expect(html).toContain('value="2026-09-02T18:00"');
expect(html).toContain('name="question" value="Pick a colour"');
expect(html).toContain("Red\nBlue");
expect(html).not.toContain('name="id"');
expect(html).not.toContain('name="pollId"');
expect(html).toContain(
'data-housekeeping-command="content.engagement.poll.delete"',
);
expect(html).toContain(
'data-housekeeping-command="content.engagement.poll-question.delete"',
);
expect(html.match(/name="reason"/gu)).toHaveLength(3);
});
it("distinguishes true not-found detail from malformed successful data", () => {
const render = (items: ContentQueryData["items"]) =>
renderToStaticMarkup(
<ContentPollWorkflow
title="Poll detail"
context={viewer}
result={ok(data(items), "detail-state")}
routeId="content.engagement.poll-detail"
list={list}
/>,
);
const missing = render([]);
expect(missing).toContain('data-housekeeping-state="empty"');
expect(missing).toContain("The requested poll was not found.");
expect(
render([{ id: "17", title: "Broken", privatePayload: {} }]),
).toContain('data-housekeeping-state="error"');
});
});
@@ -0,0 +1,586 @@
import { PERMS } from "@/lib/permission-slugs";
import type {
HousekeepingCapabilityContext,
HousekeepingResult,
} from "../../../foundation/contracts";
import {
type ContentPollDetailPayload,
type ContentPollQuestionPayload,
type ContentQueryData,
type ContentQueryItem,
isContentPollCreatePayload,
isContentPollDetailPayload,
isContentPollSummaryPayload,
} from "../queries/content-queries";
import {
type ContentCommandField,
ContentCommandForm,
} from "./content-command-form";
import { ContentPollResults } from "./poll-results";
type ContentPollRouteId =
| "content.engagement.polls"
| "content.engagement.poll-create"
| "content.engagement.poll-detail";
export interface ContentPollListState {
readonly search: string;
readonly status: string;
readonly pageSize: number;
readonly offset: number;
readonly responseQuestionId: string;
readonly responsePageSize: number;
readonly responseOffset: number;
}
interface ContentPollWorkflowProps {
readonly title: string;
readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<ContentQueryData>;
readonly routeId: ContentPollRouteId;
readonly list: ContentPollListState;
}
function PollState({
title,
state,
message,
}: {
readonly title: string;
readonly state: "loading" | "forbidden" | "error" | "empty";
readonly message: string;
}) {
return (
<section
data-housekeeping-state={state}
role={state === "forbidden" || state === "error" ? "alert" : undefined}
className="space-y-2"
>
<h1>{title}</h1>
<p>{message}</p>
</section>
);
}
function displayDate(value: string): string {
return new Intl.DateTimeFormat("en-GB", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "UTC",
}).format(new Date(value));
}
function dateTimeLocal(value: string | null): string | undefined {
if (!value) return undefined;
const parsed = new Date(value);
return Number.isFinite(parsed.getTime())
? parsed.toISOString().slice(0, 16)
: undefined;
}
function pollHref(list: ContentPollListState, offset: number): string {
const params = new URLSearchParams();
if (list.search) params.set("search", list.search);
if (list.status) params.set("status", list.status);
params.set("pageSize", String(list.pageSize));
params.set("offset", String(Math.max(0, offset)));
return `/ase-next/content/engagement/polls?${params.toString()}`;
}
function pollFields(
item?: ContentQueryItem,
detail?: ContentPollDetailPayload,
): readonly ContentCommandField[] {
return [
{
name: "title",
label: "Title",
type: "text",
required: true,
maxLength: 255,
defaultValue: item?.title,
},
{
name: "description",
label: "Description",
type: "textarea",
emptyValue: null,
maxLength: 2_000,
defaultValue: detail?.description,
},
{
name: "status",
label: "Status",
type: "select",
required: true,
defaultValue: item?.status ?? "draft",
options: [
{ value: "draft", label: "Draft" },
{ value: "active", label: "Active" },
{ value: "closed", label: "Closed" },
],
},
{
name: "showResults",
label: "Show results",
type: "checkbox",
defaultValue: detail?.showResults ?? true,
},
{
name: "multipleChoice",
label: "Allow multiple choices",
type: "checkbox",
defaultValue: detail?.multipleChoice ?? false,
},
{
name: "startsAt",
label: "Starts at (UTC)",
type: "datetime-local",
emptyValue: null,
defaultValue: dateTimeLocal(detail?.startsAt ?? null),
},
{
name: "endsAt",
label: "Ends at (UTC)",
type: "datetime-local",
emptyValue: null,
defaultValue: dateTimeLocal(detail?.endsAt ?? null),
},
];
}
function questionFields(
question?: ContentPollQuestionPayload,
): readonly ContentCommandField[] {
return [
{
name: "question",
label: "Question",
type: "text",
required: true,
maxLength: 500,
defaultValue: question?.question,
},
{
name: "type",
label: "Question type",
type: "select",
required: true,
defaultValue: question?.type ?? "single",
options: [
{ value: "single", label: "Single choice" },
{ value: "multiple", label: "Multiple choice" },
{ value: "text", label: "Free text" },
],
},
{
name: "sortOrder",
label: "Sort order",
type: "number",
min: 0,
defaultValue: question?.sortOrder ?? 0,
},
{
name: "options",
label: "Options (one per line)",
type: "textarea",
maxLength: 20_000,
defaultValue: question?.options.join("\n"),
},
];
}
function Schedule({
startsAt,
endsAt,
}: {
readonly startsAt: string | null;
readonly endsAt: string | null;
}) {
if (!startsAt && !endsAt) return <span>Not scheduled</span>;
return (
<span>
{startsAt ? (
<time dateTime={startsAt}>{displayDate(startsAt)} UTC</time>
) : (
"Open start"
)}
{" – "}
{endsAt ? (
<time dateTime={endsAt}>{displayDate(endsAt)} UTC</time>
) : (
"Open end"
)}
</span>
);
}
function PollList({
title,
context,
result,
list,
}: Pick<ContentPollWorkflowProps, "title" | "context" | "list"> & {
readonly result: Extract<HousekeepingResult<ContentQueryData>, { ok: true }>;
}) {
if (
!result.data.items.every((item) =>
isContentPollSummaryPayload(item.privatePayload),
)
) {
return (
<PollState
title={title}
state="error"
message="The Poll list is unavailable."
/>
);
}
const canEdit = context.has(PERMS.POLLS_EDIT);
const state = result.data.items.length === 0 ? "empty" : "ready";
const previousOffset = Math.max(0, list.offset - list.pageSize);
const nextOffset = list.offset + list.pageSize;
return (
<section data-housekeeping-state={state} className="space-y-5">
<header className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1>{title}</h1>
<p>Build surveys, manage their schedules, and review responses.</p>
</div>
{canEdit ? (
<a
href="/ase-next/content/engagement/polls/create"
className="rounded bg-[var(--admin-accent)] px-3 py-2 text-sm font-semibold text-[var(--admin-accent-foreground)]"
>
New poll
</a>
) : null}
</header>
<search>
<form method="get" className="grid gap-2 md:grid-cols-[1fr_auto_auto]">
<label className="sr-only" htmlFor="poll-search">
Search Polls
</label>
<input
id="poll-search"
name="search"
type="search"
defaultValue={list.search}
placeholder="Search by title"
maxLength={128}
/>
<label className="sr-only" htmlFor="poll-status">
Poll status
</label>
<select id="poll-status" name="status" defaultValue={list.status}>
<option value="">All statuses</option>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="closed">Closed</option>
</select>
<input type="hidden" name="pageSize" value={list.pageSize} />
<button type="submit">Apply filters</button>
</form>
</search>
<p className="text-sm text-[var(--admin-text-muted)]">
Showing {result.data.items.length} of {result.data.total} polls
</p>
{result.data.items.length === 0 ? (
<p>No Polls match the current filters.</p>
) : (
<ul className="grid gap-3 lg:grid-cols-2">
{result.data.items.map((item) => {
const summary = item.privatePayload;
if (!isContentPollSummaryPayload(summary)) return null;
return (
<li
key={item.id}
className="space-y-2 rounded border border-[var(--admin-border)] p-4"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<h2 className="font-semibold">
{item.href ? (
<a href={item.href}>{item.title}</a>
) : (
item.title
)}
</h2>
<span>{item.status ?? "draft"}</span>
</div>
<p>
<Schedule
startsAt={summary.startsAt}
endsAt={summary.endsAt}
/>
</p>
<p>
{summary.questionCount} questions · {summary.voterCount}{" "}
voters · {summary.answerCount} answers
</p>
<p className="text-sm text-[var(--admin-text-muted)]">
{summary.showResults ? "Results visible" : "Results hidden"} ·{" "}
{summary.multipleChoice
? "Multiple choice compatible"
: "Single answer compatibility"}
</p>
</li>
);
})}
</ul>
)}
{list.offset > 0 || nextOffset < result.data.total ? (
<nav aria-label="Poll pages" className="flex justify-between">
{list.offset > 0 ? (
<a href={pollHref(list, previousOffset)}>Previous</a>
) : (
<span />
)}
{nextOffset < result.data.total ? (
<a href={pollHref(list, nextOffset)}>Next</a>
) : null}
</nav>
) : null}
</section>
);
}
function PollCreate({
title,
context,
result,
}: Pick<ContentPollWorkflowProps, "title" | "context"> & {
readonly result: Extract<HousekeepingResult<ContentQueryData>, { ok: true }>;
}) {
if (!context.has(PERMS.POLLS_EDIT)) {
return (
<PollState
title={title}
state="forbidden"
message="You do not have permission to create Polls."
/>
);
}
const item = result.data.items[0];
if (
result.data.items.length !== 1 ||
!item ||
!isContentPollCreatePayload(item.privatePayload)
) {
return (
<PollState
title={title}
state="error"
message="The Poll creation form is unavailable."
/>
);
}
return (
<section data-housekeeping-state="ready" className="space-y-5">
<header>
<a href="/ase-next/content/engagement/polls">Back to Polls</a>
<h1>{title}</h1>
<p>Configure the Poll before adding questions from its detail page.</p>
<p className="text-sm text-[var(--admin-text-muted)]">
Schedule fields use UTC and are saved without timezone conversion.
</p>
</header>
<ContentCommandForm
commandId="content.engagement.poll.change"
buttonLabel="Create poll"
input={{ action: "create" }}
fields={pollFields()}
/>
</section>
);
}
function PollDetail({
title,
context,
result,
list,
}: Pick<ContentPollWorkflowProps, "title" | "context" | "list"> & {
readonly result: Extract<HousekeepingResult<ContentQueryData>, { ok: true }>;
}) {
const item = result.data.items[0];
if (!item) {
return (
<PollState
title={title}
state="empty"
message="The requested poll was not found."
/>
);
}
if (
result.data.items.length !== 1 ||
!isContentPollDetailPayload(item.privatePayload)
) {
return (
<PollState
title={title}
state="error"
message="The Poll details are unavailable."
/>
);
}
const detail = item.privatePayload;
const canEdit = context.has(PERMS.POLLS_EDIT);
return (
<section data-housekeeping-state="ready" className="space-y-6">
<header>
<a href="/ase-next/content/engagement/polls">Back to Polls</a>
<h1>{item.title}</h1>
<p>
{item.status ?? "draft"} · {detail.questionCount} questions ·{" "}
{detail.voterCount} voters · {detail.answerCount} answers
</p>
</header>
<section className="space-y-3" aria-labelledby="poll-overview-heading">
<h2 id="poll-overview-heading">Overview</h2>
<p className="whitespace-pre-wrap">{detail.description}</p>
<dl className="grid gap-2 sm:grid-cols-2">
<div>
<dt>Status</dt>
<dd>{item.status ?? "draft"}</dd>
</div>
<div>
<dt>Schedule</dt>
<dd>
<Schedule startsAt={detail.startsAt} endsAt={detail.endsAt} />
</dd>
</div>
<div>
<dt>Results</dt>
<dd>{detail.showResults ? "Visible" : "Hidden"}</dd>
</div>
<div>
<dt>Compatibility mode</dt>
<dd>
{detail.multipleChoice ? "Multiple choice" : "Single answer"}
</dd>
</div>
</dl>
{canEdit ? (
<>
<p className="text-sm text-[var(--admin-text-muted)]">
Schedule fields use UTC and are saved without timezone conversion.
</p>
<ContentCommandForm
commandId="content.engagement.poll.change"
buttonLabel="Save poll"
input={{ action: "update", id: item.id }}
fields={pollFields(item, detail)}
/>
</>
) : null}
</section>
<section className="space-y-4" aria-labelledby="poll-questions-heading">
<h2 id="poll-questions-heading">Questions</h2>
{detail.questions.length === 0 ? (
<p>No questions configured.</p>
) : (
<ol className="space-y-4">
{detail.questions.map((question) => (
<li
key={question.id}
className="space-y-3 rounded border border-[var(--admin-border)] p-4"
>
<h3>{question.question}</h3>
<p>
{question.type} · order {question.sortOrder} ·{" "}
{question.answerCount} answers
</p>
{question.options.length > 0 ? (
<ul>
{question.options.map((option) => (
<li key={option}>{option}</li>
))}
</ul>
) : null}
{canEdit ? (
<>
<ContentCommandForm
commandId="content.engagement.poll-question.change"
buttonLabel="Save question"
input={{ action: "update", id: question.id }}
fields={questionFields(question)}
/>
<ContentCommandForm
commandId="content.engagement.poll-question.delete"
buttonLabel="Delete question"
input={{ action: "delete", id: question.id }}
requiresReason
/>
</>
) : null}
</li>
))}
</ol>
)}
{canEdit ? (
<section className="space-y-2">
<h3>New question</h3>
<ContentCommandForm
commandId="content.engagement.poll-question.change"
buttonLabel="Add question"
input={{ action: "create", pollId: item.id }}
fields={questionFields()}
/>
</section>
) : null}
</section>
<ContentPollResults pollId={item.id} detail={detail} list={list} />
{canEdit ? (
<section className="space-y-2 rounded border border-[var(--admin-error)] p-4">
<h2>Danger zone</h2>
<p>Deleting a Poll also removes its questions and responses.</p>
<ContentCommandForm
commandId="content.engagement.poll.delete"
buttonLabel="Delete poll"
input={{ action: "delete", id: item.id }}
requiresReason
/>
</section>
) : null}
</section>
);
}
export function ContentPollWorkflow(props: ContentPollWorkflowProps) {
if (!props.result) {
return (
<PollState title={props.title} state="loading" message="Loading Polls…" />
);
}
if (!props.result.ok) {
return (
<PollState
title={props.title}
state={props.result.error.code === "FORBIDDEN" ? "forbidden" : "error"}
message={props.result.error.messageKey}
/>
);
}
if (props.routeId === "content.engagement.polls") {
return <PollList {...props} result={props.result} />;
}
if (props.routeId === "content.engagement.poll-create") {
return (
<PollCreate
title={props.title}
context={props.context}
result={props.result}
/>
);
}
return <PollDetail {...props} result={props.result} />;
}
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import { HOUSEKEEPING_MANIFESTS } from "../../manifests";
import { contentMigrationEntries } from "../../migration/content";
import { HOUSEKEEPING_ROUTE_HANDLERS } from "../../route-handlers";
@@ -8,6 +9,7 @@ import {
CONTENT_ROUTE_IDS,
CONTENT_ROUTES,
type ContentRouteId,
contentRouteById,
contentRouteGroup,
} from "./routes";
@@ -166,4 +168,10 @@ describe("Content routes", () => {
),
).toEqual(HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId));
});
it("allows Poll viewers to inspect detail results without edit access", () => {
expect(
contentRouteById("content.engagement.poll-detail").capability.slugs,
).toEqual([PERMS.POLLS_VIEW]);
});
});
@@ -131,7 +131,7 @@ export const CONTENT_ROUTES = [
contentRoute(
"content.engagement.poll-detail",
"/ase-next/content/engagement/polls/:id",
[PERMS.POLLS_EDIT],
[PERMS.POLLS_VIEW],
),
contentRoute("content.help.questions", "/ase-next/content/help/questions", [
PERMS.PAGES_VIEW,