diff --git a/src/features/housekeeping/domains/content/pages/content-pages.test.tsx b/src/features/housekeeping/domains/content/pages/content-pages.test.tsx index 6851f4f0..691ff01d 100644 --- a/src/features/housekeeping/domains/content/pages/content-pages.test.tsx +++ b/src/features/housekeeping/domains/content/pages/content-pages.test.tsx @@ -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( - , - ); - expect(html).toMatch(/name="showResults"[^>]*checked=""/u); - const multipleChoice = html.match( - /]*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( - , - ); - expect(html).toMatch(/]*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( { } expect(eventHtml).not.toContain('name="prize"'); - const pollHtml = renderToStaticMarkup( - , - ); - expect(pollHtml).toContain('name="options"'); - const prefixHtml = renderToStaticMarkup( { 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( ); } - const canPolls = context.has(PERMS.POLLS_EDIT); + if ( + routeId === "content.engagement.polls" || + routeId === "content.engagement.poll-create" || + routeId === "content.engagement.poll-detail" + ) { + return ( + + ); + } 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 ( - {canPolls && routeId?.includes("poll") ? ( - <> - - - > - ) : null} {canPrefixes && routeId === "content.engagement.prefixes" ? ( <> ); } diff --git a/src/features/housekeeping/domains/content/pages/poll-results.test.tsx b/src/features/housekeeping/domains/content/pages/poll-results.test.tsx new file mode 100644 index 00000000..0fb871a0 --- /dev/null +++ b/src/features/housekeeping/domains/content/pages/poll-results.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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"); + }); +}); diff --git a/src/features/housekeeping/domains/content/pages/poll-results.tsx b/src/features/housekeeping/domains/content/pages/poll-results.tsx new file mode 100644 index 00000000..3ab265f7 --- /dev/null +++ b/src/features/housekeeping/domains/content/pages/poll-results.tsx @@ -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 ( + + Results + + {choiceQuestions.length === 0 ? ( + No choice results are available. + ) : ( + + {choiceQuestions.map((question) => ( + + {question.question} + + {voteLabel(question.answerCount)} received + + + {question.choiceResults.map((result) => { + const percentage = Math.round( + (result.count / Math.max(1, question.answerCount)) * 100, + ); + return ( + + + {result.option} + + {voteLabel(result.count)} · {percentage}% + + + + + ); + })} + + + ))} + + )} + + + Free-text responses + {textQuestions.length === 0 ? ( + No free-text questions are configured. + ) : ( + <> + + + {textQuestions.map((question) => ( + + + {question.question} + + + ))} + + + + {detail.textResponses.items.length === 0 ? ( + No responses for this question. + ) : ( + + + + Free-text Poll responses + + + + User + Answer + Submitted + + + + {detail.textResponses.items.map((response) => ( + + + {response.username ?? "Unavailable user"} (user{" "} + {response.userId}) + + + {response.answer} + + + + {displayDate(response.createdAt)} UTC + + + + ))} + + + + )} + + {selectedQuestionId && + (list.responseOffset > 0 || + nextOffset < detail.textResponses.total) ? ( + + {list.responseOffset > 0 ? ( + + Previous + + ) : ( + + )} + {nextOffset < detail.textResponses.total ? ( + + Next + + ) : null} + + ) : null} + > + )} + + + ); +} diff --git a/src/features/housekeeping/domains/content/pages/poll-workflow.test.tsx b/src/features/housekeeping/domains/content/pages/poll-workflow.test.tsx new file mode 100644 index 00000000..664f1b1c --- /dev/null +++ b/src/features/housekeeping/domains/content/pages/poll-workflow.test.tsx @@ -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[0]["result"], +): string { + return renderToStaticMarkup( + , + ); +} + +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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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"'); + }); +}); diff --git a/src/features/housekeeping/domains/content/pages/poll-workflow.tsx b/src/features/housekeeping/domains/content/pages/poll-workflow.tsx new file mode 100644 index 00000000..0346e521 --- /dev/null +++ b/src/features/housekeeping/domains/content/pages/poll-workflow.tsx @@ -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; + readonly routeId: ContentPollRouteId; + readonly list: ContentPollListState; +} + +function PollState({ + title, + state, + message, +}: { + readonly title: string; + readonly state: "loading" | "forbidden" | "error" | "empty"; + readonly message: string; +}) { + return ( + + {title} + {message} + + ); +} + +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 Not scheduled; + return ( + + {startsAt ? ( + {displayDate(startsAt)} UTC + ) : ( + "Open start" + )} + {" – "} + {endsAt ? ( + {displayDate(endsAt)} UTC + ) : ( + "Open end" + )} + + ); +} + +function PollList({ + title, + context, + result, + list, +}: Pick & { + readonly result: Extract, { ok: true }>; +}) { + if ( + !result.data.items.every((item) => + isContentPollSummaryPayload(item.privatePayload), + ) + ) { + return ( + + ); + } + 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 ( + + + + {title} + Build surveys, manage their schedules, and review responses. + + {canEdit ? ( + + New poll + + ) : null} + + + + + + Search Polls + + + + Poll status + + + All statuses + Draft + Active + Closed + + + Apply filters + + + + + Showing {result.data.items.length} of {result.data.total} polls + + + {result.data.items.length === 0 ? ( + No Polls match the current filters. + ) : ( + + {result.data.items.map((item) => { + const summary = item.privatePayload; + if (!isContentPollSummaryPayload(summary)) return null; + return ( + + + + {item.href ? ( + {item.title} + ) : ( + item.title + )} + + {item.status ?? "draft"} + + + + + + {summary.questionCount} questions · {summary.voterCount}{" "} + voters · {summary.answerCount} answers + + + {summary.showResults ? "Results visible" : "Results hidden"} ·{" "} + {summary.multipleChoice + ? "Multiple choice compatible" + : "Single answer compatibility"} + + + ); + })} + + )} + + {list.offset > 0 || nextOffset < result.data.total ? ( + + {list.offset > 0 ? ( + Previous + ) : ( + + )} + {nextOffset < result.data.total ? ( + Next + ) : null} + + ) : null} + + ); +} + +function PollCreate({ + title, + context, + result, +}: Pick & { + readonly result: Extract, { ok: true }>; +}) { + if (!context.has(PERMS.POLLS_EDIT)) { + return ( + + ); + } + const item = result.data.items[0]; + if ( + result.data.items.length !== 1 || + !item || + !isContentPollCreatePayload(item.privatePayload) + ) { + return ( + + ); + } + return ( + + + Back to Polls + {title} + Configure the Poll before adding questions from its detail page. + + Schedule fields use UTC and are saved without timezone conversion. + + + + + ); +} + +function PollDetail({ + title, + context, + result, + list, +}: Pick & { + readonly result: Extract, { ok: true }>; +}) { + const item = result.data.items[0]; + if (!item) { + return ( + + ); + } + if ( + result.data.items.length !== 1 || + !isContentPollDetailPayload(item.privatePayload) + ) { + return ( + + ); + } + const detail = item.privatePayload; + const canEdit = context.has(PERMS.POLLS_EDIT); + return ( + + + Back to Polls + {item.title} + + {item.status ?? "draft"} · {detail.questionCount} questions ·{" "} + {detail.voterCount} voters · {detail.answerCount} answers + + + + + Overview + {detail.description} + + + Status + {item.status ?? "draft"} + + + Schedule + + + + + + Results + {detail.showResults ? "Visible" : "Hidden"} + + + Compatibility mode + + {detail.multipleChoice ? "Multiple choice" : "Single answer"} + + + + {canEdit ? ( + <> + + Schedule fields use UTC and are saved without timezone conversion. + + + > + ) : null} + + + + Questions + {detail.questions.length === 0 ? ( + No questions configured. + ) : ( + + {detail.questions.map((question) => ( + + {question.question} + + {question.type} · order {question.sortOrder} ·{" "} + {question.answerCount} answers + + {question.options.length > 0 ? ( + + {question.options.map((option) => ( + {option} + ))} + + ) : null} + {canEdit ? ( + <> + + + > + ) : null} + + ))} + + )} + {canEdit ? ( + + New question + + + ) : null} + + + + + {canEdit ? ( + + Danger zone + Deleting a Poll also removes its questions and responses. + + + ) : null} + + ); +} + +export function ContentPollWorkflow(props: ContentPollWorkflowProps) { + if (!props.result) { + return ( + + ); + } + if (!props.result.ok) { + return ( + + ); + } + if (props.routeId === "content.engagement.polls") { + return ; + } + if (props.routeId === "content.engagement.poll-create") { + return ( + + ); + } + return ; +} diff --git a/src/features/housekeeping/domains/content/routes.test.ts b/src/features/housekeeping/domains/content/routes.test.ts index 23d3bd0a..a446f2e1 100644 --- a/src/features/housekeeping/domains/content/routes.test.ts +++ b/src/features/housekeeping/domains/content/routes.test.ts @@ -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]); + }); }); diff --git a/src/features/housekeeping/domains/content/routes.ts b/src/features/housekeeping/domains/content/routes.ts index 81f306b8..469fb68a 100644 --- a/src/features/housekeeping/domains/content/routes.ts +++ b/src/features/housekeeping/domains/content/routes.ts @@ -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,
No choice results are available.
+ {voteLabel(question.answerCount)} received +
No free-text questions are configured.
No responses for this question.
{message}
Build surveys, manage their schedules, and review responses.
+ Showing {result.data.items.length} of {result.data.total} polls +
No Polls match the current filters.
+ +
+ {summary.questionCount} questions · {summary.voterCount}{" "} + voters · {summary.answerCount} answers +
+ {summary.showResults ? "Results visible" : "Results hidden"} ·{" "} + {summary.multipleChoice + ? "Multiple choice compatible" + : "Single answer compatibility"} +
Configure the Poll before adding questions from its detail page.
+ Schedule fields use UTC and are saved without timezone conversion. +
+ {item.status ?? "draft"} · {detail.questionCount} questions ·{" "} + {detail.voterCount} voters · {detail.answerCount} answers +
{detail.description}
No questions configured.
+ {question.type} · order {question.sortOrder} ·{" "} + {question.answerCount} answers +
Deleting a Poll also removes its questions and responses.