From e4e7ba7d72f5d0dd77d70a4e22c36b67da829649 Mon Sep 17 00:00:00 2001 From: Patrik Simms Date: Mon, 24 Aug 2026 21:49:15 +0200 Subject: [PATCH 1/3] feat(responses): let organizers correct submitted text Summary: - edit text answers on closed, unarchived responses behind a danger dialog - retain organizer-attributed old and new values in visible edit history - reject stale or invalid updates and mark generated books stale Rationale: - organizers need a safe way to correct contributor typos without hiding changes to submitted content - append-only history and optimistic revisions keep corrections accountable when multiple organizers share the workspace Tests: - direnv exec . env SAKEKEEP_E2E_PORT=3020 bun run verify - manual local browser check with matched screenshots Related to #81 AI-Assisted: true AI-Agent: codex AI-Model: openai/gpt-5.6-sol --- README.md | 3 +- .../20260824193733_mysterious_guardian.sql | 12 + drizzle/meta/20260824193733_snapshot.json | 819 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + e2e/auth-boundary.spec.ts | 7 + src/components/layout-page.test.tsx | 2 + src/components/submissions-panel.test.tsx | 120 ++- src/components/submissions-panel.tsx | 252 +++++- src/domain/form.ts | 18 + src/domain/submission-label.test.ts | 2 + src/domain/types.ts | 15 + src/lib/analytics.ts | 4 + src/lib/api.ts | 5 + src/routeTree.gen.ts | 23 + ...ts.$projectId.submissions.$submissionId.ts | 35 + src/routes/layout-parity.tsx | 2 + src/server/auth-policy.test.ts | 1 + src/server/db/schema.ts | 22 + src/server/organizer.ts | 34 + src/server/repository.integration.test.ts | 116 +++ src/server/repository.ts | 124 ++- src/test/fixtures.ts | 2 + .../issues/81/after-edit-confirmation.png | Bin 0 -> 108788 bytes .../issues/81/after-edit-history.png | Bin 0 -> 189773 bytes .../issues/81/before-read-only-response.png | Bin 0 -> 154982 bytes 25 files changed, 1611 insertions(+), 14 deletions(-) create mode 100644 drizzle/20260824193733_mysterious_guardian.sql create mode 100644 drizzle/meta/20260824193733_snapshot.json create mode 100644 src/routes/api.projects.$projectId.submissions.$submissionId.ts create mode 100644 src/server/organizer.ts create mode 100644 visual-artifacts/issues/81/after-edit-confirmation.png create mode 100644 visual-artifacts/issues/81/after-edit-history.png create mode 100644 visual-artifacts/issues/81/before-read-only-response.png diff --git a/README.md b/README.md index 47a1cf5..3494163 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,8 @@ Re-running `bun run db:seed` resets only those two deterministic demo projects. 4. Let a contributor recover a browser-local IndexedDB draft, including image files dropped onto or picked for the photo questions, agree to the privacy policy, and submit with an idempotency key. -5. Review read-only submissions and permanently close collection. +5. Review submissions, permanently close collection, and correct submitted text with visible edit + history when needed. 6. Create canonical millimetre-based layouts with the Fabric.js editor. 7. Generate and review one page per response plus optional standalone pages. 8. Resolve blocking text, print-area, gallery, and image-resolution problems. diff --git a/drizzle/20260824193733_mysterious_guardian.sql b/drizzle/20260824193733_mysterious_guardian.sql new file mode 100644 index 0000000..3fdbe04 --- /dev/null +++ b/drizzle/20260824193733_mysterious_guardian.sql @@ -0,0 +1,12 @@ +CREATE TABLE "submission_edits" ( + "id" uuid PRIMARY KEY NOT NULL, + "submission_id" uuid NOT NULL, + "editor_user_id" text NOT NULL, + "editor_name" text NOT NULL, + "changes" jsonb NOT NULL, + "edited_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "submissions" ADD COLUMN "revision" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "submission_edits" ADD CONSTRAINT "submission_edits_submission_id_submissions_id_fk" FOREIGN KEY ("submission_id") REFERENCES "public"."submissions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "submission_edits_submission_index" ON "submission_edits" USING btree ("submission_id"); \ No newline at end of file diff --git a/drizzle/meta/20260824193733_snapshot.json b/drizzle/meta/20260824193733_snapshot.json new file mode 100644 index 0000000..53f56a4 --- /dev/null +++ b/drizzle/meta/20260824193733_snapshot.json @@ -0,0 +1,819 @@ +{ + "id": "fd63aa4b-3c15-4902-b9cf-f61fab0b7f09", + "prevId": "89ee267d-f8cf-4a34-8166-73afccc06073", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.asset_tombstones": { + "name": "asset_tombstones", + "schema": "", + "columns": { + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "submission_id": { + "name": "submission_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "question_id": { + "name": "question_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview_object_key": { + "name": "preview_object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_mime_type": { + "name": "source_mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_name": { + "name": "source_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_project_index": { + "name": "assets_project_index", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_submission_index": { + "name": "assets_submission_index", + "columns": [ + { + "expression": "submission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_project_id_projects_id_fk": { + "name": "assets_project_id_projects_id_fk", + "tableFrom": "assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_submission_id_submissions_id_fk": { + "name": "assets_submission_id_submissions_id_fk", + "tableFrom": "assets", + "tableTo": "submissions", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.books": { + "name": "books", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "generated_book": { + "name": "generated_book", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source_fingerprint": { + "name": "source_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "books_project_id_projects_id_fk": { + "name": "books_project_id_projects_id_fk", + "tableFrom": "books", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exports": { + "name": "exports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_fingerprint": { + "name": "source_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pdf_object_key": { + "name": "pdf_object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "report_object_key": { + "name": "report_object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "report": { + "name": "report", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "exports_project_index": { + "name": "exports_project_index", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "exports_project_id_projects_id_fk": { + "name": "exports_project_id_projects_id_fk", + "tableFrom": "exports", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.layouts": { + "name": "layouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "layouts_project_position_unique": { + "name": "layouts_project_position_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "layouts_project_index": { + "name": "layouts_project_index", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "layouts_project_id_projects_id_fk": { + "name": "layouts_project_id_projects_id_fk", + "tableFrom": "layouts", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occasion": { + "name": "occasion", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "form_schema": { + "name": "form_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "form_revision": { + "name": "form_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "share_token_hash": { + "name": "share_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "book_status": { + "name": "book_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-generated'" + }, + "page_format": { + "name": "page_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'a5'" + }, + "page_orientation": { + "name": "page_orientation", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'landscape'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_share_token_hash_unique": { + "name": "projects_share_token_hash_unique", + "columns": [ + { + "expression": "share_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_updated_at_index": { + "name": "projects_updated_at_index", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_archived_at_index": { + "name": "projects_archived_at_index", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submission_edits": { + "name": "submission_edits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "submission_id": { + "name": "submission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "editor_user_id": { + "name": "editor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "editor_name": { + "name": "editor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "edited_at": { + "name": "edited_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "submission_edits_submission_index": { + "name": "submission_edits_submission_index", + "columns": [ + { + "expression": "submission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "submission_edits_submission_id_submissions_id_fk": { + "name": "submission_edits_submission_id_submissions_id_fk", + "tableFrom": "submission_edits", + "tableTo": "submissions", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submissions": { + "name": "submissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "answers": { + "name": "answers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "submissions_project_idempotency_unique": { + "name": "submissions_project_idempotency_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submissions_project_sequence_unique": { + "name": "submissions_project_sequence_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submissions_project_index": { + "name": "submissions_project_index", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "submissions_project_id_projects_id_fk": { + "name": "submissions_project_id_projects_id_fk", + "tableFrom": "submissions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index fb5e323..5ed40bb 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1787498017116, "tag": "20260823151337_productive_doorman", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1787600253179, + "tag": "20260824193733_mysterious_guardian", + "breakpoints": true } ] } \ No newline at end of file diff --git a/e2e/auth-boundary.spec.ts b/e2e/auth-boundary.spec.ts index c8cc0d1..76006fc 100644 --- a/e2e/auth-boundary.spec.ts +++ b/e2e/auth-boundary.spec.ts @@ -23,6 +23,7 @@ const organizerRequests = [ ["POST", `/api/projects/${missingProjectId}/layouts`], ["PATCH", `/api/projects/${missingProjectId}/layouts/${missingResourceId}`], ["DELETE", `/api/projects/${missingProjectId}/layouts/${missingResourceId}`], + ["PATCH", `/api/projects/${missingProjectId}/submissions/${missingResourceId}`], ["POST", `/api/projects/${missingProjectId}/book`], ["PATCH", `/api/projects/${missingProjectId}/book`], ["POST", `/api/projects/${missingProjectId}/export`], @@ -84,6 +85,12 @@ async function sendAdmitted( maxRedirects: 0, }) } + if (method === "PATCH" && pathname.includes("/submissions/")) { + return context.patch(pathname, { + data: { expectedRevision: 0, answers: { name: "Auth boundary" } }, + maxRedirects: 0, + }) + } if (method === "POST" && pathname.endsWith("/book")) { return context.post(pathname, { data: { diff --git a/src/components/layout-page.test.tsx b/src/components/layout-page.test.tsx index 07317e8..9d3afb5 100644 --- a/src/components/layout-page.test.tsx +++ b/src/components/layout-page.test.tsx @@ -254,8 +254,10 @@ describe("photo distribution in the preview", () => { const submission: SubmissionSummary = { id: "submission", sequence: 1, + revision: 0, submittedAt: "2026-07-18T00:00:00.000Z", answers: { photos: [photo("first"), photo("second")] }, + edits: [], } it("renders a different photo in each frame bound to one question", () => { diff --git a/src/components/submissions-panel.test.tsx b/src/components/submissions-panel.test.tsx index 697e25c..7c69633 100644 --- a/src/components/submissions-panel.test.tsx +++ b/src/components/submissions-panel.test.tsx @@ -1,14 +1,18 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from "@testing-library/react" -import { afterEach, describe, expect, it } from "vitest" +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" import { type Project } from "#/domain/types.ts" import { completeForm, submissionFixture } from "#/test/fixtures.ts" +import { projectApi } from "#/lib/api.ts" import { SubmissionsPanel } from "./submissions-panel.tsx" -afterEach(cleanup) +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) function project(): Project { return { @@ -63,4 +67,114 @@ describe("SubmissionsPanel", () => { expect(screen.getByText("Response 1")).toBeTruthy() }) + + it("reviews and confirms a text correction before saving it", async () => { + const currentProject = project() + const updatedProject = project() + updatedProject.submissions![0]!.answers.name = "Person one" + updatedProject.submissions![0]!.revision = 1 + const updateSubmission = vi + .spyOn(projectApi, "updateSubmission") + .mockResolvedValue(updatedProject) + const onProjectChange = vi.fn() + render( + undefined} + /> + ) + + fireEvent.click(screen.getByRole("button", { name: /Person 1/ })) + fireEvent.click(screen.getByRole("button", { name: "Edit response" })) + fireEvent.change(screen.getByLabelText("Your name"), { target: { value: "Person one" } }) + fireEvent.click(screen.getByRole("button", { name: "Review changes" })) + + expect(screen.getByRole("alertdialog").textContent).toContain( + "You are changing content submitted by a contributor." + ) + expect(screen.getByRole("alertdialog").textContent).toContain("Your name") + fireEvent.click(screen.getByRole("button", { name: "Save changes" })) + + await waitFor(() => + expect(updateSubmission).toHaveBeenCalledWith("project-id", "submission-id", { + expectedRevision: 0, + answers: { name: "Person one" }, + }) + ) + expect(onProjectChange).toHaveBeenCalledWith(updatedProject) + }) + + it("validates edited text before opening the confirmation", () => { + render( + undefined} + onRefresh={() => undefined} + /> + ) + + fireEvent.click(screen.getByRole("button", { name: /Person 1/ })) + fireEvent.click(screen.getByRole("button", { name: "Edit response" })) + fireEvent.change(screen.getByLabelText("Your name"), { target: { value: "" } }) + fireEvent.click(screen.getByRole("button", { name: "Review changes" })) + + expect(screen.getByText("This question is required.")).toBeTruthy() + expect(screen.queryByRole("alertdialog")).toBeNull() + }) + + it("shows who changed an answer and keeps the original value visible", () => { + const editedProject = project() + editedProject.submissions![0]!.edits = [ + { + id: "edit-id", + editorName: "Patrik Simms", + editedAt: "2026-08-24T18:00:00.000Z", + changes: [{ questionId: "name", previousValue: "Persno 1", newValue: "Person 1" }], + }, + ] + render( + undefined} + onRefresh={() => undefined} + /> + ) + + fireEvent.click(screen.getByRole("button", { name: /Person 1/ })) + expect(screen.getByRole("button", { name: /Person 1.*Edited/ })).toBeTruthy() + expect(screen.getByText("Edit history")).toBeTruthy() + expect(screen.getByText(/Patrik Simms/)).toBeTruthy() + expect(screen.getByText("Persno 1")).toBeTruthy() + expect( + screen + .getAllByText("Person 1") + .filter((element) => element.tagName === "SPAN" && !element.hasAttribute("class")) + ).toHaveLength(1) + }) + + it("does not offer edits before collection closes or while archived", () => { + const collecting = project() + collecting.state = "collecting" + const { rerender } = render( + undefined} + onRefresh={() => undefined} + /> + ) + fireEvent.click(screen.getByRole("button", { name: /Person 1/ })) + expect(screen.queryByRole("button", { name: "Edit response" })).toBeNull() + + const archived = project() + archived.archivedAt = "2026-08-24T18:00:00.000Z" + rerender( + undefined} + onRefresh={() => undefined} + /> + ) + expect(screen.queryByRole("button", { name: "Edit response" })).toBeNull() + }) }) diff --git a/src/components/submissions-panel.tsx b/src/components/submissions-panel.tsx index bc1b191..f361458 100644 --- a/src/components/submissions-panel.tsx +++ b/src/components/submissions-panel.tsx @@ -5,7 +5,9 @@ import { CopyIcon, ImageIcon, InboxIcon, + LoaderCircleIcon, LockIcon, + PencilIcon, RefreshCwIcon, } from "lucide-react" import { useState } from "react" @@ -16,7 +18,9 @@ import { type ImageAnswer, type Project, type SubmissionAnswer, + type SubmissionSummary, } from "#/domain/types.ts" +import { validateEditedTextAnswers, type ValidationIssue } from "#/domain/form.ts" import { AlertDialog, AlertDialogAction, @@ -46,7 +50,10 @@ import { AccordionTrigger, } from "#/components/ui/accordion.tsx" import { projectApi } from "#/lib/api.ts" +import { captureAnalyticsEvent } from "#/lib/analytics.ts" import { submissionLabel } from "#/domain/submission-label.ts" +import { Input } from "#/components/ui/input.tsx" +import { Textarea } from "#/components/ui/textarea.tsx" function questionAnswerLabel(question: FormQuestion, answer: SubmissionAnswer | undefined) { if (answer === undefined) return "No answer" @@ -101,7 +108,52 @@ export function SubmissionsPanel({ onRefresh: () => void }) { const [copied, setCopied] = useState(false) + const [editingSubmissionId, setEditingSubmissionId] = useState(null) + const [draftAnswers, setDraftAnswers] = useState>({}) + const [issues, setIssues] = useState([]) + const [confirmingSubmission, setConfirmingSubmission] = useState(null) + const [saving, setSaving] = useState(false) const submissions = project.submissions ?? [] + const textQuestions = project.formSchema.questions.filter( + (question) => question.type === "single-line" || question.type === "multiline" + ) + + const startEditing = (submission: SubmissionSummary) => { + setEditingSubmissionId(submission.id) + setDraftAnswers( + Object.fromEntries( + textQuestions.map((question) => { + const answer = submission.answers[question.id] + return [question.id, typeof answer === "string" ? answer : ""] + }) + ) + ) + setIssues([]) + } + + const changedAnswers = (submission: SubmissionSummary) => + Object.fromEntries( + textQuestions + .filter((question) => { + const answer = submission.answers[question.id] + return draftAnswers[question.id] !== (typeof answer === "string" ? answer : "") + }) + .map((question) => [question.id, draftAnswers[question.id] ?? ""]) + ) + + const reviewChanges = (submission: SubmissionSummary) => { + const nextIssues = validateEditedTextAnswers(project.formSchema, { + ...submission.answers, + ...draftAnswers, + }) + setIssues(nextIssues) + if (nextIssues.length > 0) return + if (Object.keys(changedAnswers(submission)).length === 0) { + toast.info("Change at least one text answer") + return + } + setConfirmingSubmission(submission) + } return (
@@ -109,7 +161,8 @@ export function SubmissionsPanel({

Responses

- Anonymous submissions are read-only and remain in arrival order. + Responses remain in arrival order. Text answers can be corrected after collection + closes.