Edit plus server suggestions feature (#2870)

This commit is contained in:
Kalle 2026-03-08 10:32:43 +02:00 committed by GitHub
parent 358a48e460
commit 2e9d108db2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 225 additions and 24 deletions

View File

@ -365,6 +365,7 @@ export interface PlusSuggestion {
suggestedId: number;
text: string;
tier: number;
updatedAt: number | null;
year: number;
}

View File

@ -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<DB["PlusSuggestion"]>) {
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();
}

View File

@ -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({

View File

@ -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()),

View File

@ -129,6 +129,23 @@ function suggestionHasNoOtherComments({
throw new Error(`Invalid suggestion id: ${suggestionId}`);
}
interface CanEditSuggestionArgs {
suggestionId: Tables["PlusSuggestion"]["id"];
author: Pick<Tables["User"], "id">;
user?: Pick<Tables["User"], "id">;
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<UserWithPlusTier, "id" | "plusTier">;
suggestions: PlusSuggestionRepository.FindAllByMonthItem[];

View File

@ -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 (
<>
<Outlet />
<EditSuggestionDialog suggestions={data.suggestions} />
<div className="plus__container">
<div className="stack md">
<SuggestedForInfo />
@ -254,6 +261,9 @@ export function PlusSuggestionComments({
};
defaultOpen?: true;
}) {
const { t } = useTranslation(["common"]);
const [, setSearchParams] = useSearchParams();
return (
<details open={defaultOpen} className="w-full">
<summary className="plus__view-comments-action">
@ -275,6 +285,39 @@ export function PlusSuggestionComments({
{entry.createdAtRelative}
</RelativeTime>
</span>
{entry.updatedAt ? (
<span className="plus__edited-indicator">
(
<RelativeTime
timestamp={databaseTimestampToDate(
entry.updatedAt,
).getTime()}
>
edited
</RelativeTime>
)
</span>
) : null}
{deleteButtonArgs &&
canEditSuggestion({
author: entry.author,
user: deleteButtonArgs.user,
suggestionId: entry.id,
suggestions: deleteButtonArgs.suggestions,
}) ? (
<SendouButton
className="plus__edit-button"
icon={<EditIcon />}
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 (
<SendouDialog
isOpen={Boolean(entry)}
onClose={handleClose}
heading={t("common:actions.edit")}
>
{entry ? (
<SendouForm
schema={editSuggestionFormSchema}
defaultValues={{
suggestionId: entry.id,
comment: entry.text,
}}
>
{({ FormField }) => <FormField name="comment" />}
</SendouForm>
) : null}
</SendouDialog>
);
}
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;

View File

@ -582,7 +582,7 @@ export function stringConstant<T extends string>(value: T) {
export function idConstant<T extends number>(value: T): z.ZodLiteral<T>;
export function idConstant(): RequiresDefault<z.ZodNumber>;
export function idConstant<T extends number>(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),

View File

@ -1,13 +1,14 @@
import type { z } from "zod";
import { formDataToObject } from "~/utils/remix.server";
export type ParseResult<T> =
| { success: true; data: T }
| { success: false; fieldErrors: Record<string, string> };
/**
* 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<T extends z.ZodTypeAny>({
request,
@ -16,9 +17,12 @@ export async function parseFormData<T extends z.ZodTypeAny>({
request: Request;
schema: T;
}): Promise<ParseResult<z.infer<T>>> {
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 };

View File

@ -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;
}

View File

@ -174,7 +174,7 @@ export async function safeParseRequestFormData<T extends z.ZodTypeAny>({
};
}
function formDataToObject(formData: FormData) {
export function formDataToObject(formData: FormData) {
const result: Record<string, string | string[]> = {};
for (const [key, value] of formData.entries()) {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,5 @@
export function up(db) {
db.prepare(
/* sql */ `alter table "PlusSuggestion" add column "updatedAt" integer`,
).run();
}