diff --git a/app/db/tables.ts b/app/db/tables.ts index afccd29c6..7f2a9f8c7 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -365,6 +365,7 @@ export interface PlusSuggestion { suggestedId: number; text: string; tier: number; + updatedAt: number | null; year: number; } diff --git a/app/features/plus-suggestions/PlusSuggestionRepository.server.ts b/app/features/plus-suggestions/PlusSuggestionRepository.server.ts index d7bafed2a..35bdcbcc0 100644 --- a/app/features/plus-suggestions/PlusSuggestionRepository.server.ts +++ b/app/features/plus-suggestions/PlusSuggestionRepository.server.ts @@ -4,7 +4,7 @@ import { jsonObjectFrom } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; import type { DB } from "~/db/tables"; import type { MonthYear } from "~/features/plus-voting/core"; -import { databaseTimestampToDate } from "~/utils/dates"; +import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates"; import { COMMON_USER_FIELDS } from "~/utils/kysely.server"; import type { Unwrapped } from "~/utils/types"; @@ -15,6 +15,7 @@ export async function findAllByMonth(args: MonthYear) { .select(({ eb }) => [ "PlusSuggestion.id", "PlusSuggestion.createdAt", + "PlusSuggestion.updatedAt", "PlusSuggestion.text", "PlusSuggestion.tier", jsonObjectFrom( @@ -56,6 +57,8 @@ export async function findAllByMonth(args: MonthYear) { author: Row["author"]; createdAtRelative: string; createdAt: number; + updatedAt: number | null; + updatedAtRelative: string | null; id: Row["id"]; text: Row["text"]; }>; @@ -75,6 +78,12 @@ export async function findAllByMonth(args: MonthYear) { { addSuffix: true }, ), createdAt: row.createdAt, + updatedAt: row.updatedAt, + updatedAtRelative: row.updatedAt + ? formatDistance(databaseTimestampToDate(row.updatedAt), new Date(), { + addSuffix: true, + }) + : null, author: row.author, }; if (existing) { @@ -95,6 +104,14 @@ export function create(args: Insertable) { return db.insertInto("PlusSuggestion").values(args).execute(); } +export function updateTextById(id: number, text: string) { + return db + .updateTable("PlusSuggestion") + .set({ text, updatedAt: databaseTimestampNow() }) + .where("id", "=", id) + .execute(); +} + export function deleteById(id: number) { return db.deleteFrom("PlusSuggestion").where("id", "=", id).execute(); } diff --git a/app/features/plus-suggestions/actions/plus.suggestions.server.ts b/app/features/plus-suggestions/actions/plus.suggestions.server.ts index 1ef2866b5..7f2a0f2d2 100644 --- a/app/features/plus-suggestions/actions/plus.suggestions.server.ts +++ b/app/features/plus-suggestions/actions/plus.suggestions.server.ts @@ -1,4 +1,4 @@ -import type { ActionFunction } from "react-router"; +import { type ActionFunction, redirect } from "react-router"; import { requireUser } from "~/features/auth/core/user.server"; import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server"; import { @@ -6,28 +6,65 @@ import { nextNonCompletedVoting, rangeToMonthYear, } from "~/features/plus-voting/core"; +import { parseFormData } from "~/form/parse.server"; import invariant from "~/utils/invariant"; -import { - badRequestIfFalsy, - errorToastIfFalsy, - parseRequestPayload, -} from "~/utils/remix.server"; +import { badRequestIfFalsy, errorToastIfFalsy } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; +import { plusSuggestionPage } from "~/utils/urls"; import { suggestionActionSchema } from "../plus-suggestions-schemas"; -import { canDeleteComment, isFirstSuggestion } from "../plus-suggestions-utils"; +import { + canDeleteComment, + canEditSuggestion, + isFirstSuggestion, +} from "../plus-suggestions-utils"; export const action: ActionFunction = async ({ request }) => { - const data = await parseRequestPayload({ - request, - schema: suggestionActionSchema, - }); const user = requireUser(); const votingMonthYear = rangeToMonthYear( badRequestIfFalsy(nextNonCompletedVoting(new Date())), ); + const result = await parseFormData({ + request, + schema: suggestionActionSchema, + }); + + if (!result.success) { + return { fieldErrors: result.fieldErrors }; + } + + const data = result.data; + switch (data._action) { + case "EDIT_SUGGESTION": { + const suggestions = + await PlusSuggestionRepository.findAllByMonth(votingMonthYear); + + const suggestion = suggestions.find((s) => + s.entries.some((entry) => entry.id === data.suggestionId), + ); + invariant(suggestion); + const entry = suggestion.entries.find((e) => e.id === data.suggestionId); + invariant(entry); + + errorToastIfFalsy( + canEditSuggestion({ + user, + author: entry.author, + suggestionId: data.suggestionId, + suggestions, + }), + "No permissions to edit this suggestion", + ); + + await PlusSuggestionRepository.updateTextById( + data.suggestionId, + data.comment, + ); + + throw redirect(plusSuggestionPage({ tier: suggestion.tier })); + } case "DELETE_COMMENT": { const suggestions = await PlusSuggestionRepository.findAllByMonth(votingMonthYear); @@ -55,7 +92,10 @@ export const action: ActionFunction = async ({ request }) => { if ( suggestionHasComments && - isFirstSuggestion({ suggestionId: data.suggestionId, suggestions }) + isFirstSuggestion({ + suggestionId: data.suggestionId, + suggestions, + }) ) { // admin only action await PlusSuggestionRepository.deleteWithCommentsBySuggestedUserId({ diff --git a/app/features/plus-suggestions/plus-suggestions-schemas.ts b/app/features/plus-suggestions/plus-suggestions-schemas.ts index e2fc7b844..0a8f4de32 100644 --- a/app/features/plus-suggestions/plus-suggestions-schemas.ts +++ b/app/features/plus-suggestions/plus-suggestions-schemas.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { idConstant, selectDynamic, + stringConstant, textAreaRequired, userSearch, } from "~/form/fields"; @@ -17,16 +18,25 @@ export const followUpCommentFormSchema = z.object({ }), }); +const suggestionTextFormFieldSchema = textAreaRequired({ + label: "labels.comment", + maxLength: 500, +}); + export const newSuggestionFormSchema = z.object({ tier: selectDynamic({ label: "labels.plusTier" }), userId: userSearch({ label: "labels.user" }), - comment: textAreaRequired({ - label: "labels.comment", - maxLength: 500, - }), + comment: suggestionTextFormFieldSchema, +}); + +export const editSuggestionFormSchema = z.object({ + _action: stringConstant("EDIT_SUGGESTION"), + suggestionId: idConstant(), + comment: suggestionTextFormFieldSchema, }); export const suggestionActionSchema = z.union([ + editSuggestionFormSchema, z.object({ _action: _action("DELETE_COMMENT"), suggestionId: z.preprocess(actualNumber, z.number()), diff --git a/app/features/plus-suggestions/plus-suggestions-utils.ts b/app/features/plus-suggestions/plus-suggestions-utils.ts index 816a21c49..f652fa562 100644 --- a/app/features/plus-suggestions/plus-suggestions-utils.ts +++ b/app/features/plus-suggestions/plus-suggestions-utils.ts @@ -129,6 +129,23 @@ function suggestionHasNoOtherComments({ throw new Error(`Invalid suggestion id: ${suggestionId}`); } +interface CanEditSuggestionArgs { + suggestionId: Tables["PlusSuggestion"]["id"]; + author: Pick; + user?: Pick; + suggestions: PlusSuggestionRepository.FindAllByMonthItem[]; +} +export function canEditSuggestion(args: CanEditSuggestionArgs) { + const votingActive = + process.env.NODE_ENV === "test" ? false : isVotingActive(); + + return allTruthy([ + !votingActive, + isFirstSuggestion(args), + args.author.id === args.user?.id, + ]); +} + interface CanSuggestNewUserArgs { user?: Pick; suggestions: PlusSuggestionRepository.FindAllByMonthItem[]; diff --git a/app/features/plus-suggestions/routes/plus.suggestions.tsx b/app/features/plus-suggestions/routes/plus.suggestions.tsx index 274d12b59..7c4b7a477 100644 --- a/app/features/plus-suggestions/routes/plus.suggestions.tsx +++ b/app/features/plus-suggestions/routes/plus.suggestions.tsx @@ -1,11 +1,14 @@ import clsx from "clsx"; +import { useTranslation } from "react-i18next"; import type { MetaFunction, ShouldRevalidateFunction } from "react-router"; import { Link, Outlet, useLoaderData, useSearchParams } from "react-router"; import { Alert } from "~/components/Alert"; import { Avatar } from "~/components/Avatar"; import { Catcher } from "~/components/Catcher"; import { LinkButton, SendouButton } from "~/components/elements/Button"; +import { SendouDialog } from "~/components/elements/Dialog"; import { FormWithConfirm } from "~/components/FormWithConfirm"; +import { EditIcon } from "~/components/icons/Edit"; import { TrashIcon } from "~/components/icons/Trash"; import { RelativeTime } from "~/components/RelativeTime"; import type { Tables } from "~/db/tables"; @@ -15,15 +18,18 @@ import { isVotingActive, nextNonCompletedVoting, } from "~/features/plus-voting/core"; +import { SendouForm } from "~/form/SendouForm"; import { databaseTimestampToDate } from "~/utils/dates"; import invariant from "~/utils/invariant"; import { metaTags, type SerializeFrom } from "~/utils/remix"; import { userPage } from "~/utils/urls"; import { action } from "../actions/plus.suggestions.server"; import { loader } from "../loaders/plus.suggestions.server"; +import { editSuggestionFormSchema } from "../plus-suggestions-schemas"; import { canAddCommentToSuggestionFE, canDeleteComment, + canEditSuggestion, canSuggestNewUser, } from "../plus-suggestions-utils"; export { action, loader }; @@ -70,6 +76,7 @@ export default function PlusSuggestionsPage() { return ( <> +
@@ -254,6 +261,9 @@ export function PlusSuggestionComments({ }; defaultOpen?: true; }) { + const { t } = useTranslation(["common"]); + const [, setSearchParams] = useSearchParams(); + return (
@@ -275,6 +285,39 @@ export function PlusSuggestionComments({ {entry.createdAtRelative} + {entry.updatedAt ? ( + + ( + + edited + + ) + + ) : null} + {deleteButtonArgs && + canEditSuggestion({ + author: entry.author, + user: deleteButtonArgs.user, + suggestionId: entry.id, + suggestions: deleteButtonArgs.suggestions, + }) ? ( + } + variant="minimal" + aria-label={t("common:actions.edit")} + onPress={() => + setSearchParams((prev) => { + prev.set("editingSuggestionId", String(entry.id)); + return prev; + }) + } + /> + ) : null} {deleteButtonArgs && canDeleteComment({ author: entry.author, @@ -333,4 +376,58 @@ function CommentDeleteButton({ ); } +function EditSuggestionDialog({ + suggestions, +}: { + suggestions: PlusSuggestionRepository.FindAllByMonthItem[]; +}) { + const { t } = useTranslation(["common"]); + const [searchParams, setSearchParams] = useSearchParams(); + + const editingSuggestionId = Number(searchParams.get("editingSuggestionId")); + + const entry = editingSuggestionId + ? findEntryById(suggestions, editingSuggestionId) + : null; + + const handleClose = () => { + setSearchParams((prev) => { + prev.delete("editingSuggestionId"); + return prev; + }); + }; + + return ( + + {entry ? ( + + {({ FormField }) => } + + ) : null} + + ); +} + +function findEntryById( + suggestions: PlusSuggestionRepository.FindAllByMonthItem[], + id: number, +) { + for (const suggestion of suggestions) { + for (const entry of suggestion.entries) { + if (entry.id === id) return entry; + } + } + return null; +} + export const ErrorBoundary = Catcher; diff --git a/app/form/fields.ts b/app/form/fields.ts index 02842abf1..4e4baabbd 100644 --- a/app/form/fields.ts +++ b/app/form/fields.ts @@ -582,7 +582,7 @@ export function stringConstant(value: T) { export function idConstant(value: T): z.ZodLiteral; export function idConstant(): RequiresDefault; export function idConstant(value?: T) { - const schema = value !== undefined ? z.literal(value) : id; + const schema = value !== undefined ? z.literal(value) : id.clone(); return schema.register(formRegistry, { type: "id-constant", initialValue: value, @@ -669,7 +669,7 @@ export function userSearch( > >, ) { - return id.register(formRegistry, { + return id.clone().register(formRegistry, { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), diff --git a/app/form/parse.server.ts b/app/form/parse.server.ts index 66709be78..925cc8dbc 100644 --- a/app/form/parse.server.ts +++ b/app/form/parse.server.ts @@ -1,13 +1,14 @@ import type { z } from "zod"; +import { formDataToObject } from "~/utils/remix.server"; export type ParseResult = | { success: true; data: T } | { success: false; fieldErrors: Record }; /** - * Parses JSON request body against a Zod schema. + * Parses request body against a Zod schema. + * Handles both JSON (SendouForm) and form data (FormWithConfirm) based on Content-Type. * Returns parsed data on success, or field-level errors on validation failure. - * Intended for use with SendouForm which always submits JSON. */ export async function parseFormData({ request, @@ -16,9 +17,12 @@ export async function parseFormData({ request: Request; schema: T; }): Promise>> { - const json = await request.json(); + const data = + request.headers.get("Content-Type") === "application/json" + ? await request.json() + : formDataToObject(await request.formData()); - const result = await schema.safeParseAsync(json); + const result = await schema.safeParseAsync(data); if (result.success) { return { success: true, data: result.data }; diff --git a/app/styles/plus.css b/app/styles/plus.css index 36e8fa493..3b48122a5 100644 --- a/app/styles/plus.css +++ b/app/styles/plus.css @@ -72,6 +72,16 @@ display: inline; } +.plus__edit-button { + display: inline; +} + +.plus__edited-indicator { + color: var(--text-lighter); + font-size: var(--fonts-xxs); + font-style: italic; +} + .plus__modal-select { max-width: 6rem; } diff --git a/app/utils/remix.server.ts b/app/utils/remix.server.ts index d81ca1f6d..537a20558 100644 --- a/app/utils/remix.server.ts +++ b/app/utils/remix.server.ts @@ -174,7 +174,7 @@ export async function safeParseRequestFormData({ }; } -function formDataToObject(formData: FormData) { +export function formDataToObject(formData: FormData) { const result: Record = {}; for (const [key, value] of formData.entries()) { diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 148c34270..1c7e1af83 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index 4e046e611..fb96d5f47 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index 91ded76e2..38eaf5c53 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index 009f95522..049793537 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index 478e53375..26ca28979 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index ec41f54e6..303f73b95 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index a742d2f63..e947d960d 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index 5a022e32b..36c8c7b4c 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ diff --git a/migrations/119-plus-suggestion-updated-at.js b/migrations/119-plus-suggestion-updated-at.js new file mode 100644 index 000000000..e236d7a0f --- /dev/null +++ b/migrations/119-plus-suggestion-updated-at.js @@ -0,0 +1,5 @@ +export function up(db) { + db.prepare( + /* sql */ `alter table "PlusSuggestion" add column "updatedAt" integer`, + ).run(); +}