feat(housekeeping): add dedicated polls workflow
This commit is contained in:
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,
|
||||
|
||||
Reference in new issue
Block a user