mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Edit plus server suggestions feature (#2870)
This commit is contained in:
parent
358a48e460
commit
2e9d108db2
|
|
@ -365,6 +365,7 @@ export interface PlusSuggestion {
|
|||
suggestedId: number;
|
||||
text: string;
|
||||
tier: number;
|
||||
updatedAt: number | null;
|
||||
year: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
5
migrations/119-plus-suggestion-updated-at.js
Normal file
5
migrations/119-plus-suggestion-updated-at.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export function up(db) {
|
||||
db.prepare(
|
||||
/* sql */ `alter table "PlusSuggestion" add column "updatedAt" integer`,
|
||||
).run();
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user