this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: refine response review layouts

+224 -91
+102 -88
app/(creator)/forms/[id]/responses/page.tsx
··· 8 8 import { Badge } from "@/components/ui/badge"; 9 9 import { Button } from "@/components/ui/button"; 10 10 import { Card } from "@/components/ui/card"; 11 + import { ScrollHintPanel } from "@/components/ui/scroll-hint-panel"; 11 12 import { 12 13 AGREEMENT_ANSWER_VALUES, 13 14 blockTypeTranslationKeys, ··· 166 167 {responses.map((response) => ( 167 168 <div 168 169 key={response.id} 169 - className="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-4" 170 + className="grid gap-3 px-5 py-4 sm:grid-cols-[140px_190px_120px_1fr] sm:items-center sm:gap-y-4 sm:gap-x-3" 170 171 > 171 - <div className="min-w-0"> 172 - <div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3"> 173 - <h2 className="font-display text-2xl text-[var(--ink)]"> 174 - {t("responses.submissionTitle", { 175 - number: response.submissionNumber, 176 - date: formatDate(response.submittedAt, locale), 177 - })} 178 - </h2> 179 - <p className="text-sm text-[var(--muted)]"> 180 - {t("responses.answersCount", { 181 - count: response.answerCount, 182 - })} 183 - </p> 184 - </div> 172 + <div className="min-w-0 sm:justify-self-start"> 173 + <h2 className="text-base font-semibold text-[var(--ink)] sm:text-left"> 174 + {t("meta.response", { 175 + number: response.submissionNumber, 176 + })} 177 + </h2> 185 178 </div> 179 + <p className="min-w-0 text-sm text-[var(--muted)] sm:justify-self-start sm:text-left"> 180 + {formatDate(response.submittedAt, locale)} 181 + </p> 182 + <p className="min-w-0 text-sm text-[var(--muted)] sm:justify-self-start sm:text-left"> 183 + {t("responses.answersCount", { 184 + count: response.answerCount, 185 + })} 186 + </p> 186 187 <div className="sm:flex sm:justify-end"> 187 188 <Link href={`/forms/${form.id}/responses/${response.id}`}> 188 189 <Button size="sm" variant="secondary"> ··· 247 248 </div> 248 249 249 250 {summary.kind === "choice" ? ( 250 - <div className="mt-6 grid gap-3"> 251 - {summary.optionCounts.map((option) => ( 251 + <div className="mt-6 overflow-hidden rounded-[16px] border border-[color:var(--line)] bg-[var(--bg-start)]"> 252 + {summary.optionCounts.map((option, index) => ( 252 253 <div 253 254 key={option.value} 254 - className="flex items-center justify-between rounded-[16px] border border-[color:var(--line)] bg-[var(--bg-strong)] px-4 py-3 text-sm text-[var(--ink)]" 255 + className={cn( 256 + "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-4 py-3 text-sm", 257 + index % 2 === 0 258 + ? "bg-transparent" 259 + : "bg-[var(--bg-end)]", 260 + )} 255 261 > 256 - <span className="font-medium"> 262 + <span className="font-medium text-[var(--ink)]"> 257 263 {getOptionLabel(summary, option.value)} 258 264 </span> 259 265 <span className="text-[var(--muted)]"> ··· 265 271 ) : null} 266 272 267 273 {summary.kind === "number" ? ( 268 - <div className="mt-6 grid gap-3 sm:grid-cols-3"> 269 - <div className="rounded-[16px] border border-[color:var(--line)] bg-[var(--bg-strong)] px-4 py-3 text-sm text-[var(--ink)]"> 270 - <p className="text-[var(--muted)]"> 271 - {t("responses.minValue", { 272 - value: 273 - summary.min === null 274 - ? "—" 275 - : numberFormatter.format(summary.min), 276 - })} 277 - </p> 278 - </div> 279 - <div className="rounded-[16px] border border-[color:var(--line)] bg-[var(--bg-strong)] px-4 py-3 text-sm text-[var(--ink)]"> 280 - <p className="text-[var(--muted)]"> 281 - {t("responses.maxValue", { 282 - value: 283 - summary.max === null 284 - ? "—" 285 - : numberFormatter.format(summary.max), 286 - })} 287 - </p> 288 - </div> 289 - <div className="rounded-[16px] border border-[color:var(--line)] bg-[var(--bg-strong)] px-4 py-3 text-sm text-[var(--ink)]"> 290 - <p className="text-[var(--muted)]"> 291 - {t("responses.averageValue", { 292 - value: 293 - summary.average === null 294 - ? "—" 295 - : numberFormatter.format(summary.average), 296 - })} 297 - </p> 298 - </div> 274 + <div className="mt-6 overflow-hidden rounded-[16px] border border-[color:var(--line)] bg-[var(--bg-start)]"> 275 + {[ 276 + { 277 + label: t("responses.minLabel"), 278 + value: 279 + summary.min === null 280 + ? "—" 281 + : numberFormatter.format(summary.min), 282 + }, 283 + { 284 + label: t("responses.averageLabel"), 285 + value: 286 + summary.average === null 287 + ? "—" 288 + : numberFormatter.format(summary.average), 289 + }, 290 + { 291 + label: t("responses.maxLabel"), 292 + value: 293 + summary.max === null 294 + ? "—" 295 + : numberFormatter.format(summary.max), 296 + }, 297 + ].map((row, index) => ( 298 + <div 299 + key={row.label} 300 + className={cn( 301 + "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-4 py-3 text-sm", 302 + index % 2 === 0 303 + ? "bg-transparent" 304 + : "bg-[var(--bg-end)]", 305 + )} 306 + > 307 + <span className="font-medium text-[var(--ink)]"> 308 + {row.label} 309 + </span> 310 + <span className="text-[var(--muted)]">{row.value}</span> 311 + </div> 312 + ))} 299 313 </div> 300 314 ) : null} 301 315 302 316 {summary.kind === "freeform" ? ( 303 - <div className="mt-6 rounded-[18px] border border-[color:var(--line)] bg-[var(--bg-strong)] p-4"> 304 - <p className="text-sm font-medium text-[var(--ink)]"> 305 - {t("responses.answerList")} 317 + summary.answers.length === 0 ? ( 318 + <p className="mt-6 text-sm text-[var(--muted)]"> 319 + {t("responses.noAnswersYet")} 306 320 </p> 307 - {summary.answers.length === 0 ? ( 308 - <p className="mt-3 text-sm text-[var(--muted)]"> 309 - {t("responses.noAnswersYet")} 310 - </p> 311 - ) : ( 312 - <div className="mt-3 divide-y divide-[color:var(--line)]"> 313 - {summary.answers.map((answer, index) => ( 314 - <div 315 - key={`${answer.responseId}-${answer.submissionNumber}-${index}`} 316 - className="flex flex-col gap-2 py-3 sm:flex-row sm:items-start sm:justify-between" 317 - > 318 - {summary.block.type === "LINK" ? ( 319 - <Link 320 - href={answer.value} 321 - target="_blank" 322 - rel="noreferrer" 323 - className="break-all text-sm leading-7 text-[var(--accent)] hover:underline" 324 - > 325 - {answer.value} 326 - </Link> 327 - ) : ( 328 - <p className="whitespace-pre-wrap break-words text-sm leading-7 text-[var(--ink)]"> 329 - {formatAnswerValue(summary, answer.value)} 330 - </p> 331 - )} 321 + ) : ( 322 + <ScrollHintPanel className="mt-6 max-h-80 overflow-y-auto rounded-[16px] border border-[color:var(--line)] bg-[var(--bg-start)] sm:max-h-96"> 323 + {summary.answers.map((answer, index) => ( 324 + <div 325 + key={`${answer.responseId}-${answer.submissionNumber}-${index}`} 326 + className={cn( 327 + "grid gap-3 px-4 py-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start sm:gap-4", 328 + index % 2 === 0 329 + ? "bg-transparent" 330 + : "bg-[var(--bg-end)]", 331 + )} 332 + > 333 + {summary.block.type === "LINK" ? ( 332 334 <Link 333 - href={`/forms/${form.id}/responses/${answer.responseId}`} 334 - className="text-xs font-medium text-[var(--muted)] transition hover:text-[var(--ink)]" 335 + href={answer.value} 336 + target="_blank" 337 + rel="noreferrer" 338 + className="break-all text-sm leading-7 text-[var(--accent)] hover:underline" 335 339 > 336 - {t("meta.response", { 337 - number: answer.submissionNumber, 338 - })} 340 + {answer.value} 339 341 </Link> 340 - </div> 341 - ))} 342 - </div> 343 - )} 344 - </div> 342 + ) : ( 343 + <p className="whitespace-pre-wrap break-words text-sm leading-7 text-[var(--ink)]"> 344 + {formatAnswerValue(summary, answer.value)} 345 + </p> 346 + )} 347 + <Link 348 + href={`/forms/${form.id}/responses/${answer.responseId}`} 349 + className="justify-self-start self-center text-xs font-medium text-[var(--muted)] transition hover:text-[var(--ink)] sm:justify-self-end" 350 + > 351 + {t("meta.response", { 352 + number: answer.submissionNumber, 353 + })} 354 + </Link> 355 + </div> 356 + ))} 357 + </ScrollHintPanel> 358 + ) 345 359 ) : null} 346 360 347 361 {summary.kind !== "freeform" && summary.answeredCount === 0 ? (
+72
components/ui/scroll-hint-panel.tsx
··· 1 + "use client"; 2 + 3 + import { useEffect, useRef, useState } from "react"; 4 + import { ArrowDown, ArrowUp } from "lucide-react"; 5 + 6 + import { cn } from "@/lib/utils"; 7 + 8 + export function ScrollHintPanel({ 9 + className, 10 + children, 11 + }: { 12 + className?: string; 13 + children: React.ReactNode; 14 + }) { 15 + const containerRef = useRef<HTMLDivElement | null>(null); 16 + const [canScrollUp, setCanScrollUp] = useState(false); 17 + const [canScrollDown, setCanScrollDown] = useState(false); 18 + 19 + useEffect(() => { 20 + const element = containerRef.current; 21 + 22 + if (!element) { 23 + return; 24 + } 25 + 26 + const target = element; 27 + 28 + function updateScrollState(node: HTMLDivElement) { 29 + setCanScrollUp(node.scrollTop > 4); 30 + setCanScrollDown( 31 + node.scrollTop + node.clientHeight < node.scrollHeight - 4, 32 + ); 33 + } 34 + 35 + function handleScroll() { 36 + updateScrollState(target); 37 + } 38 + 39 + updateScrollState(target); 40 + target.addEventListener("scroll", handleScroll); 41 + 42 + const resizeObserver = new ResizeObserver(() => updateScrollState(target)); 43 + resizeObserver.observe(target); 44 + 45 + return () => { 46 + target.removeEventListener("scroll", handleScroll); 47 + resizeObserver.disconnect(); 48 + }; 49 + }, []); 50 + 51 + return ( 52 + <div className="relative"> 53 + {canScrollUp ? ( 54 + <div className="pointer-events-none absolute left-1/2 top-2 z-10 -translate-x-1/2"> 55 + <div className="flex size-7 items-center justify-center rounded-full border border-[color:var(--line)] bg-[var(--surface-strong)]/95 text-[var(--ink)] shadow-[var(--shadow-elevated)] backdrop-blur-sm"> 56 + <ArrowUp className="size-4" /> 57 + </div> 58 + </div> 59 + ) : null} 60 + <div ref={containerRef} className={cn(className)}> 61 + {children} 62 + </div> 63 + {canScrollDown ? ( 64 + <div className="pointer-events-none absolute bottom-2 left-1/2 z-10 -translate-x-1/2"> 65 + <div className="flex size-7 items-center justify-center rounded-full border border-[color:var(--line)] bg-[var(--surface-strong)]/95 text-[var(--ink)] shadow-[var(--shadow-elevated)] backdrop-blur-sm"> 66 + <ArrowDown className="size-4" /> 67 + </div> 68 + </div> 69 + ) : null} 70 + </div> 71 + ); 72 + }
+3
locales/en.yml
··· 305 305 answeredCount: "Answered: {count}" 306 306 emptyCount: "No answer: {count}" 307 307 skippedCount: "Skipped by branching: {count}" 308 + minLabel: Minimum 309 + averageLabel: Average 310 + maxLabel: Maximum 308 311 minValue: "Min: {value}" 309 312 maxValue: "Max: {value}" 310 313 averageValue: "Average: {value}"
+5 -2
locales/ru.yml
··· 305 305 answeredCount: "Ответили: {count}" 306 306 emptyCount: "Без ответа: {count}" 307 307 skippedCount: "Пропущено ветвлением: {count}" 308 - minValue: "Мин: {value}" 309 - maxValue: "Макс: {value}" 308 + minLabel: Минимум 309 + averageLabel: Среднее 310 + maxLabel: Максимум 311 + minValue: "Минимум: {value}" 312 + maxValue: "Максимум: {value}" 310 313 averageValue: "Среднее: {value}" 311 314 answerList: Ответы 312 315 open: Открыть
openspec/changes/add-question-centric-response-views/.openspec.yaml openspec/changes/archive/2026-04-13-add-question-centric-response-views/.openspec.yaml
openspec/changes/add-question-centric-response-views/design.md openspec/changes/archive/2026-04-13-add-question-centric-response-views/design.md
openspec/changes/add-question-centric-response-views/proposal.md openspec/changes/archive/2026-04-13-add-question-centric-response-views/proposal.md
openspec/changes/add-question-centric-response-views/specs/response-review/spec.md openspec/changes/archive/2026-04-13-add-question-centric-response-views/specs/response-review/spec.md
openspec/changes/add-question-centric-response-views/tasks.md openspec/changes/archive/2026-04-13-add-question-centric-response-views/tasks.md
+42 -1
openspec/specs/response-review/spec.md
··· 1 - ## ADDED Requirements 1 + ## Purpose 2 2 3 + Define how authenticated creators review form responses, inspect individual submissions, export results, and understand branching context within accessible workspaces. 4 + ## Requirements 3 5 ### Requirement: Creator can list responses for owned forms 4 6 The system SHALL allow an authenticated creator to view submitted responses for personal forms they own and organization forms belonging to organizations where they are members, and SHALL prevent them from listing responses for forms outside those accessible workspaces. 5 7 ··· 43 45 #### Scenario: Creator reviews a decision point in the visited path 44 46 - **WHEN** an authenticated creator inspects a visited branching question in a submission 45 47 - **THEN** the system shows the submitted answer in context so the taken route is understandable 48 + 49 + ### Requirement: Creator can switch response review between submissions and questions 50 + The system SHALL allow an authenticated creator to switch the responses page for an accessible form between a submission-centric list and a question-centric list while keeping submission review as the default mode. 51 + 52 + #### Scenario: Creator opens responses for a form 53 + - **WHEN** an authenticated creator opens the responses view for a form they can access 54 + - **THEN** the system shows the submission-centric list by default and offers a control to switch to question-centric review 55 + 56 + #### Scenario: Creator switches to question-centric review 57 + - **WHEN** an authenticated creator selects the question-centric responses mode 58 + - **THEN** the system shows answerable form blocks in form order instead of individual submissions 59 + 60 + #### Scenario: Creator switches back to submission review 61 + - **WHEN** an authenticated creator returns to the submission-centric responses mode after viewing question-centric review 62 + - **THEN** the system shows the existing submission list again 63 + 64 + ### Requirement: Creator can inspect aggregate answers for a question 65 + The system SHALL allow an authenticated creator to inspect a question-level summary for each answerable block using stored submissions that reached that block. 66 + 67 + #### Scenario: Creator reviews a choice question 68 + - **WHEN** an authenticated creator views a single-choice, multiple-choice, or agreement block in question-centric review 69 + - **THEN** the system shows aggregate counts for the available answer options based on submissions that reached that block 70 + 71 + #### Scenario: Creator reviews a numeric question 72 + - **WHEN** an authenticated creator views a number block in question-centric review 73 + - **THEN** the system shows aggregate numeric summary information derived from submitted answers for that block 74 + 75 + #### Scenario: Creator reviews a freeform answer question 76 + - **WHEN** an authenticated creator views a short text, long text, link, or date block in question-centric review 77 + - **THEN** the system shows the submitted answers for that block together with summary counts for answered responses 78 + 79 + #### Scenario: Creator reviews a branched question 80 + - **WHEN** an authenticated creator views a question that is skipped for some submissions because of branching 81 + - **THEN** the system distinguishes submissions that reached the block from submissions that skipped it by route 82 + 83 + #### Scenario: Creator reviews a question with no submitted answers 84 + - **WHEN** an authenticated creator views a question that has no answers yet 85 + - **THEN** the system shows an empty summary state for that question instead of failing or hiding the block 86 +