Team: Manage roster page

This commit is contained in:
Kalle 2023-01-06 16:01:33 +02:00
parent 8a3178413c
commit 1170dc0bd0
21 changed files with 447 additions and 11 deletions

View File

@ -1,3 +1,4 @@
import type { TEAM_MEMBER_ROLES } from "~/features/team";
import type {
Ability,
MainWeaponId,
@ -255,12 +256,7 @@ export interface Team {
deletedAt: number | null;
}
export type MemberRole =
| "CAPTAIN"
| "FRONTLINE"
| "SUPPORT"
| "BACKLINE"
| "COACH";
export type MemberRole = typeof TEAM_MEMBER_ROLES[number];
export interface TeamMember {
teamId: number;

View File

@ -0,0 +1 @@
export { TEAM_MEMBER_ROLES } from "./team-constants";

View File

@ -0,0 +1,16 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
insert into "AllTeamMember"
("teamId", "userId")
values
(@teamId, @userId)
on conflict("teamId", "userId") do
update
set
"leftAt" = null
`);
export function addNewTeamMember(teamId: number, userId: number) {
stm.run({ teamId, userId });
}

View File

@ -0,0 +1,21 @@
import { sql } from "~/db/sql";
import type { MemberRole } from "~/db/types";
const stm = sql.prepare(/* sql */ `
update "AllTeamMember"
set "role" = @role
where "teamId" = @teamId
and "userId" = @userId
`);
export function editRole({
userId,
teamId,
role,
}: {
userId: number;
teamId: number;
role: MemberRole | null;
}) {
return stm.run({ userId, teamId, role });
}

View File

@ -28,6 +28,7 @@ const membersStm = sql.prepare(/*sql*/ `
"User"."discordName",
"User"."discordAvatar",
"User"."discordId",
"User"."discordDiscriminator",
"TeamMember"."role",
"TeamMember"."isOwner",
json_group_array("UserWeapon"."weaponSplId") as "weapons"
@ -47,7 +48,14 @@ type TeamRow =
| null;
type MemberRows = Array<
Pick<User, "id" | "discordName" | "discordAvatar" | "discordId"> &
Pick<
User,
| "id"
| "discordName"
| "discordAvatar"
| "discordId"
| "discordDiscriminator"
> &
Pick<TeamMember, "role" | "isOwner"> & { weapons: string }
>;
@ -72,6 +80,7 @@ export function findByIdentifier(customUrl: string): DetailedTeam | null {
discordAvatar: member.discordAvatar,
discordId: member.discordId,
discordName: member.discordName,
discordDiscriminator: member.discordDiscriminator,
role: member.role ?? undefined,
isOwner: Boolean(member.isOwner),
weapons: JSON.parse(member.weapons).filter(Boolean),

View File

@ -0,0 +1,11 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
select "inviteCode"
from "Team"
where "id" = @teamId
`);
export function inviteCodeById(teamId: number): string | null {
return stm.get({ teamId })?.inviteCode ?? null;
}

View File

@ -0,0 +1,13 @@
import { nanoid } from "nanoid";
import { INVITE_CODE_LENGTH } from "~/constants";
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
update "AllTeam"
set "inviteCode" = @inviteCode
where "id" = @teamId
`);
export function resetInviteLink(teamId: number) {
stm.run({ teamId, inviteCode: nanoid(INVITE_CODE_LENGTH) });
}

View File

@ -0,0 +1,23 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
update "AllTeamMember"
set "isOwner" = @isOwner
where "teamId" = @teamId
and "userId" = @userId
`);
export const transferOwnership = sql.transaction(
({
teamId,
oldOwnerUserId,
newOwnerUserId,
}: {
teamId: number;
oldOwnerUserId: number;
newOwnerUserId: number;
}) => {
stm.run({ teamId, userId: oldOwnerUserId, isOwner: 0 });
stm.run({ teamId, userId: newOwnerUserId, isOwner: 1 });
}
);

View File

@ -27,6 +27,7 @@ import { FormErrors } from "~/components/FormErrors";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { deleteTeam } from "../queries/deleteTeam.server";
import { Button } from "~/components/Button";
import { assertUnreachable } from "~/utils/types";
export const handle: SendouRouteHandle = {
i18n: ["team"],
@ -75,6 +76,9 @@ export const action: ActionFunction = async ({ request, params }) => {
return redirect(teamPage(editedTeam.customUrl));
}
default: {
assertUnreachable(data);
}
}
};

View File

@ -1,5 +1,255 @@
import type { LinksFunction } from "@remix-run/node";
import {
redirect,
type ActionFunction,
type LoaderArgs,
} from "@remix-run/node";
import { Form, useFetcher, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { useCopyToClipboard } from "react-use";
import { Alert } from "~/components/Alert";
import { Button } from "~/components/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { useBaseUrl } from "~/hooks/useBaseUrl";
import { useTranslation } from "~/hooks/useTranslation";
import { requireUser, useUser } from "~/modules/auth";
import type { SendouRouteHandle } from "~/utils/remix";
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
import { discordFullName } from "~/utils/strings";
import { assertUnreachable } from "~/utils/types";
import { joinTeamPage, teamPage } from "~/utils/urls";
import { editRole } from "../queries/editRole.server";
import { findByIdentifier } from "../queries/findByIdentifier.server";
import { inviteCodeById } from "../queries/inviteCodeById.server";
import { leaveTeam } from "../queries/leaveTeam.server";
import { resetInviteLink } from "../queries/resetInviteLink.server";
import { transferOwnership } from "../queries/transferOwnership.server";
import { TEAM_MEMBER_ROLES } from "../team-constants";
import { manageRosterSchema, teamParamsSchema } from "../team-schemas.server";
import type { DetailedTeamMember } from "../team-types";
import { isTeamFull, isTeamOwner } from "../team-utils";
import styles from "../team.css";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
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: manageRosterSchema,
});
switch (data._action) {
case "DELETE_MEMBER": {
validate(data.userId !== user.id);
leaveTeam({ teamId: team.id, userId: data.userId });
break;
}
case "RESET_INVITE_LINK": {
resetInviteLink(team.id);
break;
}
case "TRANSFER_OWNERSHIP": {
transferOwnership({
teamId: team.id,
newOwnerUserId: data.newOwnerId,
oldOwnerUserId: user.id,
});
return redirect(teamPage(customUrl));
}
case "UPDATE_MEMBER_ROLE": {
editRole({
role: data.role || null,
teamId: team.id,
userId: data.userId,
});
break;
}
default: {
assertUnreachable(data);
}
}
return null;
};
export const handle: SendouRouteHandle = {
i18n: ["team"],
// breadcrumb: () => ({
// imgPath: navIconUrl("object-damage-calculator"),
// href: OBJECT_DAMAGE_CALCULATOR_URL,
// type: "IMAGE",
// }),
};
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: { ...team, inviteCode: inviteCodeById(team.id)! },
};
};
export default function ManageTeamRosterPage() {
return <Main>manage it!</Main>;
return (
<Main className="stack lg">
<InviteCodeSection />
<MemberActions />
</Main>
);
}
function InviteCodeSection() {
const { t } = useTranslation(["common", "team"]);
const { team } = useLoaderData<typeof loader>();
const baseUrl = useBaseUrl();
const [, copyToClipboard] = useCopyToClipboard();
if (isTeamFull(team)) {
return (
<Alert variation="INFO" alertClassName="mx-auto w-max">
{t("team:roster.teamFull")}
</Alert>
);
}
const inviteLink = `${baseUrl}${joinTeamPage({
customUrl: team.customUrl,
inviteCode: team.inviteCode,
})}`;
return (
<div>
<h2 className="text-lg">{t("team:roster.inviteLink.header")}</h2>
<div className="stack md">
<div className="text-sm">{inviteLink}</div>
<Form method="post" className="stack horizontal md">
<Button size="tiny" onClick={() => copyToClipboard(inviteLink)}>
{t("common:actions.copyToClipboard")}
</Button>
<SubmitButton
variant="minimal-destructive"
_action="RESET_INVITE_LINK"
size="tiny"
>
{t("common:actions.reset")}
</SubmitButton>
</Form>
</div>
</div>
);
}
function MemberActions() {
const { t } = useTranslation(["team"]);
const { team } = useLoaderData<typeof loader>();
return (
<div className="stack md">
<h2 className="text-lg">{t("team:roster.members.header")}</h2>
<div className="team__roster__members">
{team.members.map((member) => (
<MemberRow key={member.id} member={member} />
))}
</div>
</div>
);
}
const NO_ROLE = "NO_ROLE";
function MemberRow({ member }: { member: DetailedTeamMember }) {
const { team } = useLoaderData<typeof loader>();
const { t } = useTranslation(["team"]);
const user = useUser();
const roleFetcher = useFetcher();
const isSelf = user!.id === member.id;
const role = team.members.find((m) => m.id === member.id)?.role ?? NO_ROLE;
return (
<React.Fragment key={member.id}>
<div className="team__roster__members__member">
{discordFullName(member)}
</div>
<div>
<select
defaultValue={role}
onChange={(e) =>
roleFetcher.submit(
{
_action: "UPDATE_MEMBER_ROLE",
userId: String(member.id),
role: e.target.value === NO_ROLE ? "" : e.target.value,
},
{ method: "post" }
)
}
disabled={roleFetcher.state !== "idle"}
>
<option value={NO_ROLE}>No role</option>
{TEAM_MEMBER_ROLES.map((role) => {
return (
<option key={role} value={role}>
{t(`team:roles.${role}`)}
</option>
);
})}
</select>
</div>
<div className={clsx({ invisible: isSelf })}>
<FormWithConfirm
dialogHeading={t("team:kick.header", {
teamName: team.name,
user: discordFullName(member),
})}
deleteButtonText={t("team:actionButtons.kick")}
fields={[
["_action", "DELETE_MEMBER"],
["userId", member.id],
]}
>
<Button size="tiny" variant="minimal-destructive">
{t("team:actionButtons.kick")}
</Button>
</FormWithConfirm>
</div>
<div className={clsx({ invisible: isSelf })}>
<FormWithConfirm
dialogHeading={t("team:transferOwnership.header", {
teamName: team.name,
user: discordFullName(member),
})}
deleteButtonText={t("team:actionButtons.transferOwnership.confirm")}
fields={[
["_action", "TRANSFER_OWNERSHIP"],
["newOwnerId", member.id],
]}
>
<Button size="tiny" variant="minimal-destructive">
{t("team:actionButtons.transferOwnership")}
</Button>
</FormWithConfirm>
</div>
<hr className="team__roster__separator" />
</React.Fragment>
);
}

View File

@ -3,4 +3,13 @@ export const TEAM = {
NAME_MIN_LENGTH: 2,
BIO_MAX_LENGTH: 2000,
TWITTER_MAX_LENGTH: 50,
MAX_MEMBER_COUNT: 8,
};
export const TEAM_MEMBER_ROLES = [
"CAPTAIN",
"FRONTLINE",
"SUPPORT",
"BACKLINE",
"COACH",
] as const;

View File

@ -1,6 +1,6 @@
import { z } from "zod";
import { falsyToNull } from "~/utils/zod";
import { TEAM } from "./team-constants";
import { falsyToNull, id } from "~/utils/zod";
import { TEAM, TEAM_MEMBER_ROLES } from "./team-constants";
export const teamParamsSchema = z.object({ customUrl: z.string() });
@ -21,3 +21,22 @@ export const editTeamSchema = z.union([
),
}),
]);
export const manageRosterSchema = z.union([
z.object({
_action: z.literal("RESET_INVITE_LINK"),
}),
z.object({
_action: z.literal("DELETE_MEMBER"),
userId: id,
}),
z.object({
_action: z.literal("TRANSFER_OWNERSHIP"),
newOwnerId: id,
}),
z.object({
_action: z.literal("UPDATE_MEMBER_ROLE"),
userId: id,
role: z.union([z.enum(TEAM_MEMBER_ROLES), z.literal("")]),
}),
]);

View File

@ -19,6 +19,7 @@ export interface DetailedTeamMember {
discordName: string;
discordId: string;
discordAvatar: string | null;
discordDiscriminator: string;
isOwner: boolean;
weapons: MainWeaponId[];
role?: MemberRole;

View File

@ -1,3 +1,4 @@
import { TEAM } from "./team-constants";
import type { DetailedTeam } from "./team-types";
export function isTeamOwner({
@ -23,3 +24,7 @@ export function isTeamMember({
return team.members.some((member) => member.id === user.id);
}
export function isTeamFull(team: DetailedTeam) {
return team.members.length >= TEAM.MAX_MEMBER_COUNT;
}

View File

@ -192,6 +192,25 @@
margin-block-start: var(--s-2);
}
.team__roster__members {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--s-4);
place-items: center;
max-width: max-content;
}
.team__roster__members__member {
justify-self: flex-start;
font-weight: var(--bold);
font-size: var(--fonts-sm);
}
.team__roster__separator {
grid-column: 1 / 3;
width: 100%;
}
@media screen and (min-width: 640px) {
.team__banner__flags {
display: flex;
@ -220,4 +239,12 @@
.team__member-card__container {
display: none;
}
.team__roster__members {
grid-template-columns: 1fr 1fr max-content max-content;
}
.team__roster__separator {
display: none;
}
}

7
app/hooks/useBaseUrl.ts Normal file
View File

@ -0,0 +1,7 @@
import { useMatches } from "@remix-run/react";
export function useBaseUrl() {
const matches = useMatches();
return matches[0]?.data["baseUrl"] as string;
}

View File

@ -80,6 +80,7 @@ export interface RootLoaderData {
locale: string;
theme: Theme | null;
patrons: FindAllPatrons;
baseUrl: string;
user?: Pick<
UserWithPlusTier,
| "id"
@ -103,6 +104,7 @@ export const loader: LoaderFunction = async ({ request }) => {
locale,
theme: themeSession.getTheme(),
patrons: db.users.findAllPatrons(),
baseUrl: process.env["BASE_URL"],
user: user
? {
discordName: user.discordName,

View File

@ -272,6 +272,12 @@ select {
padding-inline: var(--s-3) var(--s-8);
}
select:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: initial;
}
/* Temporary solution for issue: https://github.com/Sendouc/sendou.ink/issues/1141 */
.light select {
/* TODO: Get color from CSS var */

View File

@ -106,6 +106,13 @@ export const editTeamPage = (customUrl: string) =>
`${teamPage(customUrl)}/edit`;
export const manageTeamRosterPage = (customUrl: string) =>
`${teamPage(customUrl)}/roster`;
export const joinTeamPage = ({
customUrl,
inviteCode,
}: {
customUrl: string;
inviteCode: string;
}) => `${teamPage(customUrl)}/join?code=${inviteCode}`;
export const authErrorUrl = (errorCode: AuthErrorCode) =>
`/?authError=${errorCode}`;

View File

@ -40,6 +40,7 @@
"actions.add": "Add",
"actions.remove": "Remove",
"actions.delete": "Delete",
"actions.reset": "Reset",
"actions.loadMore": "Load more",
"actions.copyToClipboard": "Copy to clipboard",
"actions.close": "Close",

View File

@ -5,6 +5,11 @@
"actionButtons.editTeam": "Edit Team",
"actionButtons.manageRoster": "Manage Roster",
"actionButtons.deleteTeam": "Delete Team",
"actionButtons.kick": "Kick",
"kick.header": "Kick {{user}} from {{teamName}}?",
"actionButtons.transferOwnership": "Transfer Ownership",
"transferOwnership.header": "Transfer ownership of {{teamName}} to {{user}}?",
"actionButtons.transferOwnership.confirm": "Transfer",
"deleteTeam.header": "Are you sure you want to delete {{teamName}}?",
"roles.CAPTAIN": "Captain",
"roles.FRONTLINE": "Frontline",
@ -14,5 +19,8 @@
"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"
"forms.errors.duplicateName": "There is already a team with this name",
"roster.teamFull": "Team is full",
"roster.inviteLink.header": "Share invite link to add members",
"roster.members.header": "Members"
}