Teams: Edit page

This commit is contained in:
Kalle
2023-01-04 23:03:46 +02:00
parent 077e2356ae
commit 9d9ffac6e9
12 changed files with 250 additions and 6 deletions

View File

@@ -1,7 +1,12 @@
import { useActionData } from "@remix-run/react";
import type { CustomTypeOptions } from "react-i18next";
import { useTranslation } from "~/hooks/useTranslation";
export function FormErrors({ namespace }: { namespace: "user" | "calendar" }) {
export function FormErrors({
namespace,
}: {
namespace: keyof CustomTypeOptions["resources"];
}) {
const { t } = useTranslation(["common", namespace]);
const actionData = useActionData<{ errors?: string[] }>();

View File

@@ -0,0 +1,29 @@
import { sql } from "~/db/sql";
import type { Team } from "~/db/types";
const stm = sql.prepare(/*sql*/ `
update "Team"
set
"name" = @name,
"customUrl" = @customUrl,
"bio" = @bio,
"twitter" = @twitter
where "id" = @id
returning *
`);
export function edit({
id,
name,
customUrl,
bio,
twitter,
}: Pick<Team, "id" | "name" | "customUrl" | "bio" | "twitter">) {
return stm.get({
id,
name,
customUrl,
bio,
twitter,
}) as Team;
}

View File

@@ -19,15 +19,18 @@ const teamStm = sql.prepare(/*sql*/ `
left join "TeamMember" on "TeamMember"."teamId" = "t"."id"
left join "User" on "User"."id" = "TeamMember"."userId"
where "t"."customUrl" = @customUrl
and "t"."deletedAt" is null
group by "t"."id"
`);
const membersStm = sql.prepare(/*sql*/ `
select
"User"."id",
"User"."discordName",
"User"."discordAvatar",
"User"."discordId",
"TeamMember"."role",
"TeamMember"."isOwner",
json_group_array("UserWeapon"."weaponSplId") as "weapons"
from "TeamMember"
join "User" on "User"."id" = "TeamMember"."userId"
@@ -45,18 +48,19 @@ type TeamRow =
| null;
type MemberRows = Array<
Pick<User, "discordName" | "discordAvatar" | "discordId"> &
Pick<TeamMember, "role"> & { weapons: string }
Pick<User, "id" | "discordName" | "discordAvatar" | "discordId"> &
Pick<TeamMember, "role" | "isOwner"> & { weapons: string }
>;
export function findByIdentifier(customUrl: string): DetailedTeam | null {
const team = teamStm.get({ customUrl }) as TeamRow;
const team = teamStm.get({ customUrl: customUrl.toLowerCase() }) as TeamRow;
if (!team) return null;
const members = membersStm.all({ teamId: team.id }) as MemberRows;
return {
id: team.id,
name: team.name,
twitter: team.twitter ?? undefined,
bio: team.bio ?? undefined,
@@ -66,10 +70,12 @@ export function findByIdentifier(customUrl: string): DetailedTeam | null {
bannerSrc: team.bannerSrc,
countries: removeDuplicates(JSON.parse(team.countries).filter(Boolean)),
members: members.map((member) => ({
id: member.id,
discordAvatar: member.discordAvatar,
discordId: member.discordId,
discordName: member.discordName,
role: member.role ?? undefined,
isOwner: Boolean(member.isOwner),
weapons: JSON.parse(member.weapons).filter(Boolean),
})),
results: undefined,

View File

@@ -0,0 +1,162 @@
import {
type LoaderArgs,
redirect,
type ActionFunction,
} from "@remix-run/node";
import * as React from "react";
import { Form, useLoaderData } from "@remix-run/react";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { useTranslation } from "~/hooks/useTranslation";
import { requireUser } from "~/modules/auth";
import {
notFoundIfFalsy,
parseRequestFormData,
type SendouRouteHandle,
validate,
} from "~/utils/remix";
import { mySlugify, teamPage } from "~/utils/urls";
import { edit } from "../queries/edit.server";
import { findByIdentifier } from "../queries/findByIdentifier.server";
import { TEAM } from "../team-constants";
import { editTeamSchema, teamParamsSchema } from "../team-schemas.server";
import { isTeamOwner } from "../team-utils";
import { FormErrors } from "~/components/FormErrors";
export const handle: SendouRouteHandle = {
i18n: ["team"],
// breadcrumb: () => ({
// imgPath: navIconUrl("object-damage-calculator"),
// href: OBJECT_DAMAGE_CALCULATOR_URL,
// type: "IMAGE",
// }),
};
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUser(request);
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfFalsy(findByIdentifier(customUrl));
validate(isTeamOwner({ team, user }));
const data = await parseRequestFormData({
request,
schema: editTeamSchema,
});
const newCustomUrl = mySlugify(data.name);
const existingTeam = findByIdentifier(newCustomUrl);
// can't take someone else's custom url
if (existingTeam && existingTeam.id !== team.id) {
return {
errors: ["forms.errors.duplicateName"],
};
}
const editedTeam = edit({
id: team.id,
customUrl: newCustomUrl,
...data,
});
return redirect(teamPage(editedTeam.customUrl));
};
export const loader = async ({ request, params }: LoaderArgs) => {
const user = await requireUser(request);
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfFalsy(findByIdentifier(customUrl));
if (!isTeamOwner({ team, user })) {
throw redirect(teamPage(customUrl));
}
return { team };
};
export default function EditTeamPage() {
const { t } = useTranslation(["common"]);
return (
<Main className="half-width">
<Form method="post" className="stack md items-start">
<NameInput />
<TwitterInput />
<BioTextarea />
<SubmitButton className="mt-4">
{t("common:actions.submit")}
</SubmitButton>
<FormErrors namespace="team" />
</Form>
</Main>
);
}
function NameInput() {
const { t } = useTranslation(["common", "team"]);
const { team } = useLoaderData<typeof loader>();
return (
<div>
<Label htmlFor="title" required>
{t("common:forms.name")}
</Label>
<input
name="name"
required
minLength={TEAM.NAME_MIN_LENGTH}
maxLength={TEAM.NAME_MAX_LENGTH}
defaultValue={team.name}
/>
<FormMessage type="info">{t("team:forms.info.name")}</FormMessage>
</div>
);
}
function TwitterInput() {
const { t } = useTranslation(["team"]);
const { team } = useLoaderData<typeof loader>();
return (
<div>
<Label htmlFor="title" required>
{t("team:forms.fields.teamTwitter")}
</Label>
<input
name="twitter"
required
maxLength={TEAM.TWITTER_MAX_LENGTH}
defaultValue={team.twitter}
/>
</div>
);
}
function BioTextarea() {
const { t } = useTranslation(["team"]);
const { team } = useLoaderData<typeof loader>();
const [value, setValue] = React.useState(team.bio ?? "");
return (
<div className="u-edit__bio-container">
<Label
htmlFor="bio"
valueLimits={{ current: value.length, max: TEAM.BIO_MAX_LENGTH }}
>
{t("team:forms.fields.bio")}
</Label>
<textarea
id="bio"
name="bio"
value={value}
onChange={(e) => setValue(e.target.value)}
maxLength={TEAM.BIO_MAX_LENGTH}
/>
</div>
);
}

View File

@@ -51,7 +51,7 @@ export const handle: SendouRouteHandle = {
export const loader = ({ params }: LoaderArgs) => {
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfFalsy(findByIdentifier(customUrl.toLowerCase()));
const team = notFoundIfFalsy(findByIdentifier(customUrl));
return { team };
};

View File

@@ -0,0 +1,6 @@
export const TEAM = {
NAME_MAX_LENGTH: 64,
NAME_MIN_LENGTH: 2,
BIO_MAX_LENGTH: 2000,
TWITTER_MAX_LENGTH: 50,
};

View File

@@ -1,3 +1,17 @@
import { z } from "zod";
import { falsyToNull } from "~/utils/zod";
import { TEAM } from "./team-constants";
export const teamParamsSchema = z.object({ customUrl: z.string() });
export const editTeamSchema = z.object({
name: z.string().min(TEAM.NAME_MIN_LENGTH).max(TEAM.NAME_MAX_LENGTH),
bio: z.preprocess(
falsyToNull,
z.string().max(TEAM.BIO_MAX_LENGTH).nullable()
),
twitter: z.preprocess(
falsyToNull,
z.string().max(TEAM.TWITTER_MAX_LENGTH).nullable()
),
});

View File

@@ -2,6 +2,7 @@ import type { MemberRole } from "~/db/types";
import type { MainWeaponId } from "~/modules/in-game-lists";
export interface DetailedTeam {
id: number;
name: string;
bio?: string;
twitter?: string;
@@ -15,9 +16,11 @@ export interface DetailedTeam {
}
export interface DetailedTeamMember {
id: number;
discordName: string;
discordId: string;
discordAvatar: string | null;
isOwner: boolean;
weapons: MainWeaponId[];
role?: MemberRole;
}

View File

@@ -0,0 +1,11 @@
import type { DetailedTeam } from "./team-types";
export function isTeamOwner({
team,
user,
}: {
team: DetailedTeam;
user: { id: number };
}) {
return team.members.some((member) => member.isOwner && member.id === user.id);
}

View File

@@ -100,6 +100,8 @@ export const userResultsEditHighlightsPage = (user: UserLinkArgs) =>
export const userNewBuildPage = (user: UserLinkArgs) =>
`${userBuildsPage(user)}/new`;
export const teamPage = (customUrl: string) => `/t/${customUrl}`;
export const authErrorUrl = (errorCode: AuthErrorCode) =>
`/?authError=${errorCode}`;
export const impersonateUrl = (idToLogInAs: number) =>

View File

@@ -3,5 +3,9 @@
"roles.FRONTLINE": "Frontline",
"roles.SUPPORT": "Support",
"roles.BACKLINE": "Backline",
"roles.COACH": "Coach"
"roles.COACH": "Coach",
"forms.fields.teamTwitter": "Team Twitter",
"forms.fields.bio": "Bio",
"forms.info.name": "Note that if you change your team's name then someone else can claim the name and URL for their team",
"forms.errors.duplicateName": "There is already a team with this name"
}

View File

@@ -21,7 +21,9 @@ module.exports = {
route("/to/:id/teams", "features/tournament/routes/to.$id.teams.tsx");
route("/to/:id/join", "features/tournament/routes/to.$id.join.tsx");
});
route("/t/:customUrl", "features/team/routes/t.$customUrl.tsx");
route("/t/:customUrl/edit", "features/team/routes/t.$customUrl.edit.tsx");
});
},
};