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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user