Team: Leave feature

This commit is contained in:
Kalle
2023-01-06 11:05:24 +02:00
parent 7c72fb2057
commit b922b54d5b
14 changed files with 146 additions and 9 deletions

View File

@@ -1,6 +1,7 @@
import { Form } from "@remix-run/react";
import React from "react";
import invariant from "tiny-invariant";
import { useTranslation } from "~/hooks/useTranslation";
import { Button } from "./Button";
import { Dialog } from "./Dialog";
@@ -8,11 +9,14 @@ export function FormWithConfirm({
fields,
children,
dialogHeading,
deleteButtonText,
}: {
fields?: [name: string, value: string | number][];
children: React.ReactNode;
dialogHeading: string;
deleteButtonText?: string;
}) {
const { t } = useTranslation(["common"]);
const [dialogOpen, setDialogOpen] = React.useState(false);
const formRef = React.useRef<HTMLFormElement>(null);
const id = React.useId();
@@ -34,9 +38,9 @@ export function FormWithConfirm({
<h2 className="text-sm">{dialogHeading}</h2>
<div className="stack horizontal md justify-center">
<Button form={id} variant="destructive" type="submit">
Delete
{deleteButtonText ?? t("common:actions.delete")}
</Button>
<Button onClick={closeDialog}>Cancel</Button>
<Button onClick={closeDialog}>{t("common:actions.cancel")}</Button>
</div>
</div>
</Dialog>

View File

@@ -69,7 +69,7 @@ export function seed() {
function wipeDB() {
const tablesToDelete = [
"UnvalidatedUserSubmittedImage",
"TeamMember",
"AllTeamMember",
"AllTeam",
"Build",
"TournamentTeamMember",
@@ -909,7 +909,7 @@ function detailedTeam() {
sql
.prepare(
/*sql*/ `
insert into "TeamMember" ("teamId", "userId", "role", "isOwner", "leftAt")
insert into "AllTeamMember" ("teamId", "userId", "role", "isOwner", "leftAt")
values (
1,
${userId},

View File

@@ -9,6 +9,7 @@ const teamStm = sql.prepare(/*sql*/ `
"t"."name",
"t"."twitter",
"t"."bio",
"t"."customUrl",
"ia"."url" as "avatarSrc",
"ib"."url" as "bannerSrc",
json_group_array("User"."country") as "countries"
@@ -38,7 +39,7 @@ const membersStm = sql.prepare(/*sql*/ `
`);
type TeamRow =
| (Pick<Team, "id" | "name" | "twitter" | "bio"> & {
| (Pick<Team, "id" | "name" | "twitter" | "bio" | "customUrl"> & {
avatarSrc: string;
bannerSrc: string;
countries: string;
@@ -60,6 +61,7 @@ export function findByIdentifier(customUrl: string): DetailedTeam | null {
return {
id: team.id,
name: team.name,
customUrl: team.customUrl,
twitter: team.twitter ?? undefined,
bio: team.bio ?? undefined,
avatarSrc: team.avatarSrc,

View File

@@ -0,0 +1,18 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
update "AllTeamMember"
set "leftAt" = strftime('%s', 'now')
where "teamId" = @teamId
and "userId" = @userId
`);
export function leaveTeam({
teamId,
userId,
}: {
teamId: number;
userId: number;
}) {
stm.run({ teamId, userId });
}

View File

@@ -0,0 +1,5 @@
import { Main } from "~/components/Main";
export default function ManageTeamRosterPage() {
return <Main>manage it!</Main>;
}

View File

@@ -1,4 +1,5 @@
import type {
ActionFunction,
LinksFunction,
LoaderArgs,
MetaFunction,
@@ -8,18 +9,23 @@ import { Link, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import React from "react";
import { Avatar } from "~/components/Avatar";
import { Button, LinkButton } from "~/components/Button";
import { Flag } from "~/components/Flag";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { WeaponImage } from "~/components/Image";
import { Main } from "~/components/Main";
import { Placement } from "~/components/Placement";
import { useTranslation } from "~/hooks/useTranslation";
import type { SendouRouteHandle } from "~/utils/remix";
import { requireUser, useUser } from "~/modules/auth";
import { type SendouRouteHandle, validate } from "~/utils/remix";
import { notFoundIfFalsy } from "~/utils/remix";
import { makeTitle } from "~/utils/strings";
import { userPage } from "~/utils/urls";
import { editTeamPage, manageTeamRosterPage, userPage } from "~/utils/urls";
import { findByIdentifier } from "../queries/findByIdentifier.server";
import { leaveTeam } from "../queries/leaveTeam.server";
import { teamParamsSchema } from "../team-schemas.server";
import type { DetailedTeamMember, TeamResultPeek } from "../team-types";
import { isTeamMember, isTeamOwner } from "../team-utils";
import styles from "../team.css";
export const meta: MetaFunction = ({
@@ -39,6 +45,19 @@ 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(isTeamMember({ user, team }) && !isTeamOwner({ user, team }));
leaveTeam({ userId: user.id, teamId: team.id });
return null;
};
export const handle: SendouRouteHandle = {
i18n: ["team"],
// breadcrumb: () => ({
@@ -66,6 +85,7 @@ export default function TeamPage() {
{/* <InfoBadges /> */}
</div>
<MobileTeamNameCountry />
<ActionButtons />
{team.results ? <ResultsBanner results={team.results} /> : null}
{team.bio ? <article>{team.bio}</article> : null}
<div className="stack lg">
@@ -152,6 +172,51 @@ function MobileTeamNameCountry() {
);
}
function ActionButtons() {
const { t } = useTranslation(["team"]);
const user = useUser();
const { team } = useLoaderData<typeof loader>();
if (!isTeamMember({ user, team })) {
return null;
}
return (
<div className="team__action-buttons">
{!isTeamOwner({ user, team }) ? (
<FormWithConfirm
dialogHeading={t("team:leaveTeam.header", { teamName: team.name })}
deleteButtonText={t("team:actionButtons.leaveTeam.confirm")}
>
<Button size="tiny" variant="destructive">
{t("team:actionButtons.leaveTeam")}
</Button>
</FormWithConfirm>
) : null}
{isTeamOwner({ user, team }) ? (
<LinkButton
size="tiny"
to={manageTeamRosterPage(team.customUrl)}
variant="outlined"
prefetch="intent"
>
{t("team:actionButtons.manageRoster")}
</LinkButton>
) : null}
{isTeamOwner({ user, team }) ? (
<LinkButton
size="tiny"
to={editTeamPage(team.customUrl)}
variant="outlined"
prefetch="intent"
>
{t("team:actionButtons.editTeam")}
</LinkButton>
) : null}
</div>
);
}
function ResultsBanner({ results }: { results: TeamResultPeek }) {
return (
<Link className="team__results" to="results">

View File

@@ -3,6 +3,7 @@ import type { MainWeaponId } from "~/modules/in-game-lists";
export interface DetailedTeam {
id: number;
customUrl: string;
name: string;
bio?: string;
twitter?: string;

View File

@@ -5,7 +5,21 @@ export function isTeamOwner({
user,
}: {
team: DetailedTeam;
user: { id: number };
user?: { id: number };
}) {
if (!user) return false;
return team.members.some((member) => member.isOwner && member.id === user.id);
}
export function isTeamMember({
team,
user,
}: {
team: DetailedTeam;
user?: { id: number };
}) {
if (!user) return false;
return team.members.some((member) => member.id === user.id);
}

View File

@@ -90,6 +90,12 @@
height: 1.5rem;
}
.team__action-buttons {
display: flex;
justify-content: flex-end;
gap: var(--s-2);
}
.team__results {
background-color: var(--bg-lightest);
max-width: 32rem;

View File

@@ -101,6 +101,10 @@ export const userNewBuildPage = (user: UserLinkArgs) =>
`${userBuildsPage(user)}/new`;
export const teamPage = (customUrl: string) => `/t/${customUrl}`;
export const editTeamPage = (customUrl: string) =>
`${teamPage(customUrl)}/edit`;
export const manageTeamRosterPage = (customUrl: string) =>
`${teamPage(customUrl)}/roster`;
export const authErrorUrl = (errorCode: AuthErrorCode) =>
`/?authError=${errorCode}`;

View File

@@ -47,7 +47,7 @@ module.exports.up = function (db) {
db.prepare(
/*sql*/ `
create table "TeamMember" (
create table "AllTeamMember" (
"teamId" integer not null,
"userId" integer not null,
"role" text,
@@ -60,4 +60,12 @@ module.exports.up = function (db) {
) strict
`
).run();
db.prepare(
/*sql*/ `
create view "TeamMember"
as
select * from "AllTeamMember" where "leftAt" is null
`
).run();
};

View File

@@ -43,6 +43,7 @@
"actions.loadMore": "Load more",
"actions.copyToClipboard": "Copy to clipboard",
"actions.close": "Close",
"actions.cancel": "Cancel",
"actions.loading": "Loading...",
"actions.clear": "Clear",
"actions.selectAll": "Select All",

View File

@@ -1,4 +1,9 @@
{
"actionButtons.leaveTeam": "Leave Team",
"leaveTeam.header": "Are you sure you want to leave {{teamName}}?",
"actionButtons.leaveTeam.confirm": "Leave",
"actionButtons.editTeam": "Edit Team",
"actionButtons.manageRoster": "Manage Roster",
"roles.CAPTAIN": "Captain",
"roles.FRONTLINE": "Frontline",
"roles.SUPPORT": "Support",

View File

@@ -24,6 +24,10 @@ module.exports = {
route("/t/:customUrl", "features/team/routes/t.$customUrl.tsx");
route("/t/:customUrl/edit", "features/team/routes/t.$customUrl.edit.tsx");
route(
"/t/:customUrl/roster",
"features/team/routes/t.$customUrl.roster.tsx"
);
});
},
};