mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-02 16:20:36 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import * as TeamFactory from "~/db/seed/factories/TeamFactory";
|
||||
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
|
||||
import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { db } from "~/db/sql";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import * as TeamRepository from "./TeamRepository.server";
|
||||
|
||||
describe("findResultsById", () => {
|
||||
test("participant who was on the roster at the result's time but left later is not a sub", async () => {
|
||||
const [owner, formerMember] = await UserFactory.createMany(2);
|
||||
const team = await TeamFactory.create({
|
||||
memberUserIds: [owner.id, formerMember.id],
|
||||
});
|
||||
|
||||
const tournament = await TournamentFactory.create({ authorId: owner.id });
|
||||
const tournamentTeam = await TournamentTeamFactory.create({
|
||||
tournamentId: tournament.id,
|
||||
memberUserIds: [owner.id, formerMember.id],
|
||||
team: {
|
||||
name: team.name,
|
||||
prefersNotToHost: 0,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
await TournamentRepository.finalize({
|
||||
tournamentId: tournament.id,
|
||||
season: undefined,
|
||||
summary: {
|
||||
skills: [],
|
||||
seedingSkills: [],
|
||||
mapResultDeltas: [],
|
||||
playerResultDeltas: [],
|
||||
tournamentResults: [owner.id, formerMember.id].map((userId) => ({
|
||||
userId,
|
||||
placement: 1,
|
||||
participantCount: 8,
|
||||
tournamentTeamId: tournamentTeam.id,
|
||||
div: null,
|
||||
})),
|
||||
spDiffs: null,
|
||||
setResults: new Map(),
|
||||
},
|
||||
});
|
||||
|
||||
const { startsAt } = await db
|
||||
.selectFrom("CalendarEventDate")
|
||||
.select("startsAt")
|
||||
.where("eventId", "=", tournament.eventId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
// biome-ignore lint/plugin: a membership spanning a past event start only arises with the passage of time; `backdate` can't address AllTeamMember as it has no id column
|
||||
await db
|
||||
.updateTable("AllTeamMember")
|
||||
.set({ createdAt: startsAt - 1000, leftAt: startsAt + 1000 })
|
||||
.where("teamId", "=", team.id)
|
||||
.where("userId", "=", formerMember.id)
|
||||
.execute();
|
||||
|
||||
const results = await TeamRepository.findResultsById(team.id);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].subs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
742
apps/web-react/app/features/team/TeamRepository.server.ts
Normal file
742
apps/web-react/app/features/team/TeamRepository.server.ts
Normal file
@@ -0,0 +1,742 @@
|
||||
import { type Insertable, sql, type Transaction } from "kysely";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, Tables } from "~/db/tables";
|
||||
import type { CustomTheme, UserMapModePreferences } from "~/db/tables-json";
|
||||
import { actorId } from "~/features/auth/core/user.server";
|
||||
import * as LFGRepository from "~/features/lfg/LFGRepository.server";
|
||||
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server";
|
||||
import { NON_PLAYER_TEAM_ROLES } from "~/features/team/team-constants";
|
||||
import { subsOfResult } from "~/features/team/team-utils";
|
||||
import { databaseTimestampNow } from "~/utils/dates";
|
||||
import { shortNanoid } from "~/utils/id";
|
||||
import invariant from "~/utils/invariant";
|
||||
import {
|
||||
commonUserSelect,
|
||||
concatUserSubmittedImagePrefix,
|
||||
jsonArrayFrom,
|
||||
tournamentLogoOrNull,
|
||||
userProfileWeapons,
|
||||
} from "~/utils/kysely.server";
|
||||
import { toDBBoolean } from "~/utils/sql";
|
||||
import { mySlugify } from "~/utils/urls";
|
||||
|
||||
export function findAllUndisbanded() {
|
||||
return db
|
||||
.selectFrom("Team")
|
||||
.leftJoin("UserSubmittedImage", "UserSubmittedImage.id", "Team.avatarImgId")
|
||||
.select(({ eb }) => [
|
||||
"Team.customUrl",
|
||||
"Team.name",
|
||||
"Team.tag",
|
||||
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
|
||||
"avatarUrl",
|
||||
),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
// AllTeamMember directly instead of the TeamMemberWithSecondary view:
|
||||
// the view's team-existence check is redundant when joining to Team
|
||||
.selectFrom("AllTeamMember")
|
||||
.innerJoin("User", "User.id", "AllTeamMember.userId")
|
||||
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
|
||||
.select(["User.id", "User.username", "PlusTier.tier as plusTier"])
|
||||
.whereRef("AllTeamMember.teamId", "=", "Team.id")
|
||||
.where("AllTeamMember.leftAt", "is", null)
|
||||
.orderBy("AllTeamMember.order", "asc"),
|
||||
).as("members"),
|
||||
])
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function searchByName({
|
||||
query,
|
||||
limit,
|
||||
}: {
|
||||
query: string;
|
||||
limit: number;
|
||||
}) {
|
||||
return db
|
||||
.selectFrom("Team")
|
||||
.leftJoin("UserSubmittedImage", "UserSubmittedImage.id", "Team.avatarImgId")
|
||||
.select(({ eb }) => [
|
||||
"Team.id",
|
||||
"Team.customUrl",
|
||||
"Team.name",
|
||||
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
|
||||
"avatarUrl",
|
||||
),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("TeamMemberWithSecondary")
|
||||
.innerJoin("User", "User.id", "TeamMemberWithSecondary.userId")
|
||||
.select(["User.id", "User.username", "User.tournamentName"])
|
||||
.whereRef("TeamMemberWithSecondary.teamId", "=", "Team.id")
|
||||
.where((eb2) =>
|
||||
eb2.and([
|
||||
eb2.or([
|
||||
eb2("TeamMemberWithSecondary.role", "is", null),
|
||||
eb2(
|
||||
"TeamMemberWithSecondary.role",
|
||||
"not in",
|
||||
NON_PLAYER_TEAM_ROLES,
|
||||
),
|
||||
]),
|
||||
eb2.or([
|
||||
eb2("TeamMemberWithSecondary.roleType", "is", null),
|
||||
eb2("TeamMemberWithSecondary.roleType", "!=", "OTHER"),
|
||||
]),
|
||||
]),
|
||||
)
|
||||
.orderBy("TeamMemberWithSecondary.order", "asc"),
|
||||
).as("members"),
|
||||
])
|
||||
.where("Team.name", "like", `%${query}%`)
|
||||
.orderBy("Team.name", "asc")
|
||||
.limit(limit)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function findById(teamId: number) {
|
||||
return db
|
||||
.selectFrom("AllTeam")
|
||||
.select(["AllTeam.id", "AllTeam.name"])
|
||||
.where("AllTeam.id", "=", teamId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
export function findAllMemberOfByUserId(userId: number) {
|
||||
return db
|
||||
.selectFrom("TeamMemberWithSecondary")
|
||||
.innerJoin("Team", "Team.id", "TeamMemberWithSecondary.teamId")
|
||||
.leftJoin("UserSubmittedImage", "UserSubmittedImage.id", "Team.avatarImgId")
|
||||
.select(({ eb }) => [
|
||||
"Team.id",
|
||||
"Team.customUrl",
|
||||
"Team.name",
|
||||
"Team.mapModePreferences",
|
||||
"TeamMemberWithSecondary.role",
|
||||
"TeamMemberWithSecondary.customRole",
|
||||
"TeamMemberWithSecondary.isOwner",
|
||||
"TeamMemberWithSecondary.isManager",
|
||||
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
|
||||
"logoUrl",
|
||||
),
|
||||
])
|
||||
.where("TeamMemberWithSecondary.userId", "=", userId)
|
||||
.orderBy("TeamMemberWithSecondary.isMainTeam", "desc")
|
||||
.orderBy("Team.name", "asc")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export type findByCustomUrl = NonNullable<
|
||||
Awaited<ReturnType<typeof findByCustomUrl>>
|
||||
>;
|
||||
|
||||
export async function findByCustomUrl(
|
||||
customUrl: string,
|
||||
{
|
||||
includeInviteCode = false,
|
||||
includeUnvalidatedImages = false,
|
||||
includeMapModePreferences = false,
|
||||
} = {},
|
||||
) {
|
||||
// join the unvalidated table (instead of the validated-only `UserSubmittedImage` view) so the
|
||||
// edit page can preview images still pending moderation; for everyone else the url is gated on
|
||||
// `validatedAt` so pending images stay hidden
|
||||
const row = await db
|
||||
.selectFrom("Team")
|
||||
.leftJoin(
|
||||
"UnvalidatedUserSubmittedImage as AvatarImage",
|
||||
"AvatarImage.id",
|
||||
"Team.avatarImgId",
|
||||
)
|
||||
.leftJoin(
|
||||
"UnvalidatedUserSubmittedImage as BannerImage",
|
||||
"BannerImage.id",
|
||||
"Team.bannerImgId",
|
||||
)
|
||||
.select(({ eb }) => [
|
||||
"Team.id",
|
||||
"Team.name",
|
||||
"Team.bsky",
|
||||
"Team.bio",
|
||||
"Team.tag",
|
||||
"Team.customUrl",
|
||||
"Team.customTheme",
|
||||
"Team.avatarImgId",
|
||||
"Team.bannerImgId",
|
||||
concatUserSubmittedImagePrefix(
|
||||
includeUnvalidatedImages
|
||||
? eb.ref("AvatarImage.url")
|
||||
: eb.fn<string | null>("iif", [
|
||||
eb("AvatarImage.validatedAt", "is not", null),
|
||||
eb.ref("AvatarImage.url"),
|
||||
sql`null`,
|
||||
]),
|
||||
).as("avatarUrl"),
|
||||
concatUserSubmittedImagePrefix(
|
||||
includeUnvalidatedImages
|
||||
? eb.ref("BannerImage.url")
|
||||
: eb.fn<string | null>("iif", [
|
||||
eb("BannerImage.validatedAt", "is not", null),
|
||||
eb.ref("BannerImage.url"),
|
||||
sql`null`,
|
||||
]),
|
||||
).as("bannerUrl"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("TeamMemberWithSecondary")
|
||||
.innerJoin("User", "User.id", "TeamMemberWithSecondary.userId")
|
||||
.select(({ eb: innerEb }) => [
|
||||
...commonUserSelect(innerEb),
|
||||
"TeamMemberWithSecondary.role",
|
||||
"TeamMemberWithSecondary.customRole",
|
||||
"TeamMemberWithSecondary.roleType",
|
||||
"TeamMemberWithSecondary.isOwner",
|
||||
"TeamMemberWithSecondary.isManager",
|
||||
"TeamMemberWithSecondary.isMainTeam",
|
||||
"User.country",
|
||||
"User.patronTier",
|
||||
userProfileWeapons(innerEb).as("weapons"),
|
||||
])
|
||||
.whereRef("TeamMemberWithSecondary.teamId", "=", "Team.id")
|
||||
.orderBy("TeamMemberWithSecondary.order", "asc"),
|
||||
).as("members"),
|
||||
])
|
||||
.$if(includeInviteCode, (qb) => qb.select("Team.inviteCode"))
|
||||
.$if(includeMapModePreferences, (qb) =>
|
||||
qb.select("Team.mapModePreferences"),
|
||||
)
|
||||
.where("Team.customUrl", "=", customUrl.toLowerCase())
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) return;
|
||||
|
||||
const managerIds = row.members
|
||||
.filter((member) => member.isOwner || member.isManager)
|
||||
.map((member) => member.id);
|
||||
|
||||
return {
|
||||
...row,
|
||||
permissions: {
|
||||
EDIT: managerIds,
|
||||
MANAGE_ROSTER: managerIds,
|
||||
DELETE: row.members
|
||||
.filter((member) => member.isOwner)
|
||||
.map((member) => member.id),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type FindResultPlacementsById = NonNullable<
|
||||
Awaited<ReturnType<typeof findResultPlacementsById>>
|
||||
>;
|
||||
|
||||
export function findResultPlacementsById(teamId: number) {
|
||||
return db
|
||||
.selectFrom("TournamentTeam")
|
||||
.innerJoin(
|
||||
"TournamentResult",
|
||||
"TournamentResult.tournamentTeamId",
|
||||
"TournamentTeam.id",
|
||||
)
|
||||
.select(["TournamentResult.placement"])
|
||||
.where("teamId", "=", teamId)
|
||||
.groupBy("TournamentResult.tournamentId")
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves tournament results for a given team by its ID.
|
||||
*/
|
||||
export async function findResultsById(teamId: number) {
|
||||
const rows = await db
|
||||
.with("results", (db) =>
|
||||
db
|
||||
.selectFrom("TournamentTeam")
|
||||
.innerJoin(
|
||||
"TournamentResult",
|
||||
"TournamentResult.tournamentTeamId",
|
||||
"TournamentTeam.id",
|
||||
)
|
||||
.select([
|
||||
"TournamentResult.userId",
|
||||
"TournamentResult.tournamentTeamId",
|
||||
"TournamentResult.tournamentId",
|
||||
"TournamentResult.placement",
|
||||
"TournamentResult.participantCount",
|
||||
])
|
||||
.where("teamId", "=", teamId)
|
||||
.groupBy("TournamentResult.tournamentId"),
|
||||
)
|
||||
.selectFrom("results")
|
||||
.innerJoin(
|
||||
"CalendarEvent",
|
||||
"CalendarEvent.tournamentId",
|
||||
"results.tournamentId",
|
||||
)
|
||||
.innerJoin(
|
||||
"CalendarEventDate",
|
||||
"CalendarEventDate.eventId",
|
||||
"CalendarEvent.id",
|
||||
)
|
||||
.innerJoin("Tournament", "Tournament.id", "results.tournamentId")
|
||||
.select((eb) => [
|
||||
"results.placement",
|
||||
"results.tournamentId",
|
||||
"results.participantCount",
|
||||
"results.tournamentTeamId",
|
||||
"CalendarEvent.name as tournamentName",
|
||||
"CalendarEventDate.startsAt",
|
||||
"Tournament.tier",
|
||||
tournamentLogoOrNull(eb).as("logoUrl"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("results as results2")
|
||||
.innerJoin("TournamentResult", (join) =>
|
||||
join
|
||||
.onRef(
|
||||
"TournamentResult.tournamentTeamId",
|
||||
"=",
|
||||
"results2.tournamentTeamId",
|
||||
)
|
||||
.onRef(
|
||||
"TournamentResult.tournamentId",
|
||||
"=",
|
||||
"results2.tournamentId",
|
||||
),
|
||||
)
|
||||
.innerJoin("User", "User.id", "TournamentResult.userId")
|
||||
.whereRef("results2.tournamentId", "=", "results.tournamentId")
|
||||
.select((eb) => commonUserSelect(eb)),
|
||||
).as("participants"),
|
||||
])
|
||||
.orderBy("CalendarEventDate.startsAt", "desc")
|
||||
.execute();
|
||||
|
||||
const members = await allMembersById(teamId);
|
||||
|
||||
return rows.map((row) => {
|
||||
const subs = subsOfResult(row, members);
|
||||
|
||||
return {
|
||||
...row,
|
||||
subs,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// reads AllTeamMember instead of the TeamMemberWithSecondary view because the
|
||||
// view filters out members who have left, and past members are exactly what
|
||||
// subsOfResult needs to tell substitutes apart from since-departed roster members
|
||||
function allMembersById(teamId: number) {
|
||||
return db
|
||||
.selectFrom("AllTeamMember")
|
||||
.select([
|
||||
"AllTeamMember.userId",
|
||||
"AllTeamMember.leftAt",
|
||||
"AllTeamMember.createdAt",
|
||||
])
|
||||
.where("AllTeamMember.teamId", "=", teamId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function findAllByMemberUserId(
|
||||
userId: number,
|
||||
trx?: Transaction<DB>,
|
||||
) {
|
||||
return (trx ?? db)
|
||||
.selectFrom("TeamMemberWithSecondary")
|
||||
.innerJoin("Team", "Team.id", "TeamMemberWithSecondary.teamId")
|
||||
.select((eb) => [
|
||||
"TeamMemberWithSecondary.teamId as id",
|
||||
"Team.name",
|
||||
"TeamMemberWithSecondary.isOwner",
|
||||
"TeamMemberWithSecondary.isMainTeam",
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("TeamMemberWithSecondary as m2")
|
||||
.innerJoin("User", "User.id", "m2.userId")
|
||||
.select((eb) => [...commonUserSelect(eb), "m2.role", "m2.roleType"])
|
||||
.whereRef("TeamMemberWithSecondary.teamId", "=", "m2.teamId")
|
||||
.orderBy("m2.order", "asc"),
|
||||
).as("members"),
|
||||
])
|
||||
.where("userId", "=", userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function insert(
|
||||
args: Pick<Insertable<Tables["Team"]>, "name"> & {
|
||||
ownerUserId: number;
|
||||
isMainTeam: boolean;
|
||||
},
|
||||
) {
|
||||
const customUrl = mySlugify(args.name);
|
||||
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const team = await trx
|
||||
.insertInto("AllTeam")
|
||||
.values({
|
||||
name: args.name,
|
||||
customUrl,
|
||||
inviteCode: shortNanoid(),
|
||||
})
|
||||
.returning(["id", "customUrl"])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await trx
|
||||
.insertInto("AllTeamMember")
|
||||
.values({
|
||||
userId: args.ownerUserId,
|
||||
teamId: team.id,
|
||||
isOwner: 1,
|
||||
isMainTeam: toDBBoolean(args.isMainTeam),
|
||||
})
|
||||
.execute();
|
||||
|
||||
return team;
|
||||
});
|
||||
}
|
||||
|
||||
export async function update({
|
||||
id,
|
||||
name,
|
||||
bio,
|
||||
bsky,
|
||||
tag,
|
||||
avatarImgId,
|
||||
bannerImgId,
|
||||
}: Pick<
|
||||
Insertable<Tables["Team"]>,
|
||||
"id" | "name" | "bio" | "bsky" | "tag" | "avatarImgId" | "bannerImgId"
|
||||
>) {
|
||||
const customUrl = mySlugify(name);
|
||||
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const current = await trx
|
||||
.selectFrom("Team")
|
||||
.select(["avatarImgId", "bannerImgId"])
|
||||
.where("id", "=", id)
|
||||
.executeTakeFirst();
|
||||
|
||||
// images that got removed or replaced are no longer referenced by anything,
|
||||
// so their submitted image rows are cleaned up
|
||||
const orphanedImageIds: number[] = [];
|
||||
if (current?.avatarImgId && current.avatarImgId !== avatarImgId) {
|
||||
orphanedImageIds.push(current.avatarImgId);
|
||||
}
|
||||
if (current?.bannerImgId && current.bannerImgId !== bannerImgId) {
|
||||
orphanedImageIds.push(current.bannerImgId);
|
||||
}
|
||||
|
||||
if (orphanedImageIds.length > 0) {
|
||||
await trx
|
||||
.deleteFrom("UnvalidatedUserSubmittedImage")
|
||||
.where("id", "in", orphanedImageIds)
|
||||
.execute();
|
||||
}
|
||||
|
||||
return trx
|
||||
.updateTable("AllTeam")
|
||||
.set({
|
||||
name,
|
||||
customUrl,
|
||||
bio,
|
||||
bsky,
|
||||
tag,
|
||||
avatarImgId,
|
||||
bannerImgId,
|
||||
})
|
||||
.where("id", "=", id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCustomTheme({
|
||||
id,
|
||||
customTheme,
|
||||
}: {
|
||||
id: number;
|
||||
customTheme: CustomTheme | null;
|
||||
}) {
|
||||
await db
|
||||
.updateTable("AllTeam")
|
||||
.set({
|
||||
customTheme: customTheme ? JSON.stringify(customTheme) : null,
|
||||
})
|
||||
.where("id", "=", id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Updates the team's SendouQ map/mode preferences, or clears them when passed `null`. Keeps existing map pools of modes missing from the new value. */
|
||||
export async function updateMapModePreferences({
|
||||
id,
|
||||
mapModePreferences,
|
||||
}: {
|
||||
id: number;
|
||||
mapModePreferences: UserMapModePreferences | null;
|
||||
}) {
|
||||
if (!mapModePreferences) {
|
||||
await db
|
||||
.updateTable("AllTeam")
|
||||
.set({ mapModePreferences: null })
|
||||
.where("id", "=", id)
|
||||
.execute();
|
||||
return;
|
||||
}
|
||||
|
||||
const current = await db
|
||||
.selectFrom("Team")
|
||||
.select("Team.mapModePreferences")
|
||||
.where("Team.id", "=", id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const merged: UserMapModePreferences = {
|
||||
...mapModePreferences,
|
||||
pool: MatchProfileRepository.mergeExcludedModePreferences(
|
||||
mapModePreferences.pool,
|
||||
current.mapModePreferences?.pool,
|
||||
),
|
||||
};
|
||||
|
||||
await db
|
||||
.updateTable("AllTeam")
|
||||
.set({ mapModePreferences: JSON.stringify(merged) })
|
||||
.where("id", "=", id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function switchOwnMainTeam(teamId: number) {
|
||||
const userId = actorId();
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const currentTeams = await findAllByMemberUserId(userId, trx);
|
||||
|
||||
const teamToSwitchTo = currentTeams.find((team) => team.id === teamId);
|
||||
invariant(teamToSwitchTo, "User is not a member of this team");
|
||||
|
||||
await trx
|
||||
.updateTable("AllTeamMember")
|
||||
.set({
|
||||
isMainTeam: 0,
|
||||
})
|
||||
.where("userId", "=", userId)
|
||||
.execute();
|
||||
|
||||
await trx
|
||||
.updateTable("AllTeamMember")
|
||||
.set({
|
||||
isMainTeam: 1,
|
||||
})
|
||||
.where("userId", "=", userId)
|
||||
.where("teamId", "=", teamId)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteById(teamId: number) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const members = await trx
|
||||
.selectFrom("TeamMember")
|
||||
.select(["TeamMember.userId"])
|
||||
.where("teamId", "=", teamId)
|
||||
.execute();
|
||||
|
||||
// switch main team to another if they at least one secondary team
|
||||
for (const member of members) {
|
||||
const currentTeams = await findAllByMemberUserId(member.userId, trx);
|
||||
|
||||
const teamToSwitchTo = currentTeams.find((team) => team.id !== teamId);
|
||||
|
||||
if (!teamToSwitchTo) continue;
|
||||
|
||||
await trx
|
||||
.updateTable("AllTeamMember")
|
||||
.set({
|
||||
isMainTeam: 1,
|
||||
})
|
||||
.where("userId", "=", member.userId)
|
||||
.where("teamId", "=", teamToSwitchTo.id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
await trx
|
||||
.updateTable("AllTeamMember")
|
||||
.set({
|
||||
isMainTeam: 0,
|
||||
})
|
||||
.where("AllTeamMember.teamId", "=", teamId)
|
||||
.execute();
|
||||
|
||||
await LFGRepository.deletePostsByTeamId(teamId, trx);
|
||||
|
||||
await trx
|
||||
.updateTable("AllTeam")
|
||||
.set({
|
||||
deletedAt: databaseTimestampNow(),
|
||||
})
|
||||
.where("id", "=", teamId)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
export function resetInviteCode(teamId: number) {
|
||||
return db
|
||||
.updateTable("AllTeam")
|
||||
.set({
|
||||
inviteCode: shortNanoid(),
|
||||
})
|
||||
.where("id", "=", teamId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function insertOwnMembership({
|
||||
teamId,
|
||||
maxTeamsAllowed,
|
||||
}: {
|
||||
teamId: number;
|
||||
maxTeamsAllowed: number;
|
||||
}) {
|
||||
const userId = actorId();
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const teamCount = (await findAllByMemberUserId(userId, trx)).length;
|
||||
|
||||
if (teamCount >= maxTeamsAllowed) {
|
||||
throw new Error("Trying to exceed allowed team count");
|
||||
}
|
||||
|
||||
const isMainTeam = toDBBoolean(teamCount === 0);
|
||||
|
||||
const maxOrder = await trx
|
||||
.selectFrom("AllTeamMember")
|
||||
.select((eb) =>
|
||||
eb.fn.coalesce(eb.fn.max("order"), sql<number>`-1`).as("maxOrder"),
|
||||
)
|
||||
.where("teamId", "=", teamId)
|
||||
.where("leftAt", "is", null)
|
||||
.executeTakeFirst();
|
||||
const order = (maxOrder?.maxOrder ?? -1) + 1;
|
||||
|
||||
await trx
|
||||
.insertInto("AllTeamMember")
|
||||
.values({ userId, teamId, isMainTeam, order })
|
||||
.onConflict((oc) =>
|
||||
oc.columns(["userId", "teamId"]).doUpdateSet({
|
||||
leftAt: null,
|
||||
isMainTeam,
|
||||
order,
|
||||
}),
|
||||
)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
export function handleMemberLeaving({
|
||||
userId,
|
||||
teamId,
|
||||
newOwnerUserId,
|
||||
}: {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
newOwnerUserId?: number;
|
||||
}) {
|
||||
return db
|
||||
.transaction()
|
||||
.execute((trx) => memberLeave(trx, { userId, teamId, newOwnerUserId }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a roster edit in a single transaction: updates each kept member's role
|
||||
* & editor status and removes (kicks) the members in `kickedUserIds`.
|
||||
*/
|
||||
export function updateRoster({
|
||||
teamId,
|
||||
members,
|
||||
kickedUserIds,
|
||||
}: {
|
||||
teamId: number;
|
||||
members: Array<{
|
||||
userId: number;
|
||||
role: Tables["TeamMember"]["role"];
|
||||
customRole: Tables["TeamMember"]["customRole"];
|
||||
roleType: Tables["TeamMember"]["roleType"];
|
||||
isManager: boolean;
|
||||
order: number;
|
||||
}>;
|
||||
kickedUserIds: number[];
|
||||
}) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
for (const userId of kickedUserIds) {
|
||||
await memberLeave(trx, { userId, teamId });
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
await trx
|
||||
.updateTable("AllTeamMember")
|
||||
.set({
|
||||
role: member.role,
|
||||
customRole: member.customRole,
|
||||
roleType: member.roleType,
|
||||
isManager: member.isManager ? 1 : 0,
|
||||
order: member.order,
|
||||
})
|
||||
.where("teamId", "=", teamId)
|
||||
.where("userId", "=", member.userId)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function memberLeave(
|
||||
trx: Transaction<DB>,
|
||||
{
|
||||
userId,
|
||||
teamId,
|
||||
newOwnerUserId,
|
||||
}: { userId: number; teamId: number; newOwnerUserId?: number },
|
||||
) {
|
||||
const currentTeams = await findAllByMemberUserId(userId, trx);
|
||||
|
||||
const teamToLeave = currentTeams.find((team) => team.id === teamId);
|
||||
invariant(teamToLeave, "User is not a member of this team");
|
||||
invariant(
|
||||
!teamToLeave.isOwner || newOwnerUserId,
|
||||
"New owner id must be provided when old is leaving",
|
||||
);
|
||||
|
||||
const wasMainTeam = teamToLeave.isMainTeam;
|
||||
const newMainTeam = currentTeams.find((team) => team.id !== teamId);
|
||||
if (wasMainTeam && newMainTeam) {
|
||||
await trx
|
||||
.updateTable("AllTeamMember")
|
||||
.set({
|
||||
isMainTeam: 1,
|
||||
})
|
||||
.where("userId", "=", userId)
|
||||
.where("teamId", "=", newMainTeam.id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
await trx
|
||||
.updateTable("AllTeamMember")
|
||||
.set({
|
||||
leftAt: databaseTimestampNow(),
|
||||
isMainTeam: 0,
|
||||
isOwner: 0,
|
||||
isManager: 0,
|
||||
})
|
||||
.where("userId", "=", userId)
|
||||
.where("teamId", "=", teamId)
|
||||
.execute();
|
||||
if (newOwnerUserId) {
|
||||
await trx
|
||||
.updateTable("AllTeamMember")
|
||||
.set({
|
||||
isOwner: 1,
|
||||
isManager: 0,
|
||||
})
|
||||
.where("userId", "=", newOwnerUserId)
|
||||
.where("teamId", "=", teamId)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { REGULAR_USER_TEST_ID } from "~/db/seed/constants";
|
||||
import * as TeamFactory from "~/db/seed/factories/TeamFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import * as ImageRepository from "~/features/img-upload/ImageRepository.server";
|
||||
import * as TeamRepository from "~/features/team/TeamRepository.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { clampThemeToGamut } from "~/utils/oklch-gamut";
|
||||
import { wrappedAction } from "~/utils/Test";
|
||||
import type { editTeamActionSchema } from "../team-schemas";
|
||||
import { action as _editTeamProfileAction } from "./t.$customUrl.edit.server";
|
||||
|
||||
const editTeamProfileAction = wrappedAction<typeof editTeamActionSchema>({
|
||||
action: _editTeamProfileAction,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
|
||||
const DEFAULT_EDIT_FIELDS = {
|
||||
_action: "EDIT",
|
||||
name: "Team 1",
|
||||
bio: "",
|
||||
bsky: "",
|
||||
tag: "",
|
||||
logo: null,
|
||||
banner: null,
|
||||
} as const;
|
||||
|
||||
const VALID_CUSTOM_THEME = {
|
||||
baseHue: 180,
|
||||
baseChroma: 0.05,
|
||||
accentHue: 200,
|
||||
accentChroma: 0.1,
|
||||
chatHue: null,
|
||||
radiusBox: 3,
|
||||
radiusField: 2,
|
||||
radiusSelector: 2,
|
||||
borderWidth: 2,
|
||||
sizeField: 1,
|
||||
sizeSelector: 1,
|
||||
sizeSpacing: 1,
|
||||
} as const;
|
||||
|
||||
const expectedStoredTheme = () =>
|
||||
JSON.parse(JSON.stringify(clampThemeToGamut(VALID_CUSTOM_THEME)));
|
||||
|
||||
describe("team page editing", () => {
|
||||
let customUrl: string;
|
||||
|
||||
const createTeam = async (
|
||||
options?: Parameters<typeof TeamFactory.create>[1],
|
||||
) => {
|
||||
const team = await TeamFactory.create(
|
||||
{ name: "Team 1", memberUserIds: [REGULAR_USER_TEST_ID] },
|
||||
options,
|
||||
);
|
||||
customUrl = team.customUrl;
|
||||
};
|
||||
|
||||
const teamRow = async () => {
|
||||
const team = await TeamRepository.findByCustomUrl(customUrl);
|
||||
invariant(team, `No team with the custom url ${customUrl}`);
|
||||
|
||||
return team;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// a patron because setting a custom theme is a patron only feature
|
||||
await UserFactory.createRegular(null, { patronTier: 2 });
|
||||
});
|
||||
|
||||
describe("custom theme", () => {
|
||||
beforeEach(() => createTeam());
|
||||
|
||||
test("sets a custom theme via UPDATE_CUSTOM_THEME", async () => {
|
||||
const response = await editTeamProfileAction(
|
||||
{
|
||||
_action: "UPDATE_CUSTOM_THEME",
|
||||
newValue: VALID_CUSTOM_THEME,
|
||||
},
|
||||
{ user: "regular", params: { customUrl } },
|
||||
);
|
||||
|
||||
expect(response).toEqual({ ok: true });
|
||||
expect((await teamRow()).customTheme).toEqual(expectedStoredTheme());
|
||||
});
|
||||
|
||||
test("clears a custom theme via UPDATE_CUSTOM_THEME with null", async () => {
|
||||
await editTeamProfileAction(
|
||||
{
|
||||
_action: "UPDATE_CUSTOM_THEME",
|
||||
newValue: VALID_CUSTOM_THEME,
|
||||
},
|
||||
{ user: "regular", params: { customUrl } },
|
||||
);
|
||||
|
||||
const response = await editTeamProfileAction(
|
||||
{
|
||||
_action: "UPDATE_CUSTOM_THEME",
|
||||
newValue: null,
|
||||
},
|
||||
{ user: "regular", params: { customUrl } },
|
||||
);
|
||||
|
||||
expect(response).toEqual({ ok: true });
|
||||
expect((await teamRow()).customTheme).toBeNull();
|
||||
});
|
||||
|
||||
test("prevents setting an invalid custom theme", async () => {
|
||||
const response = await editTeamProfileAction(
|
||||
{
|
||||
_action: "UPDATE_CUSTOM_THEME",
|
||||
newValue: {
|
||||
...VALID_CUSTOM_THEME,
|
||||
baseHue: 500, // Invalid: max is 360
|
||||
},
|
||||
},
|
||||
{ user: "regular", params: { customUrl } },
|
||||
);
|
||||
|
||||
expect(response.fieldErrors["newValue.baseHue"]).toBeTruthy();
|
||||
});
|
||||
|
||||
test("preserves an existing custom theme when editing the team profile", async () => {
|
||||
await editTeamProfileAction(
|
||||
{
|
||||
_action: "UPDATE_CUSTOM_THEME",
|
||||
newValue: VALID_CUSTOM_THEME,
|
||||
},
|
||||
{ user: "regular", params: { customUrl } },
|
||||
);
|
||||
|
||||
const response = await editTeamProfileAction(
|
||||
{ ...DEFAULT_EDIT_FIELDS, bio: "Updated bio" },
|
||||
{ user: "regular", params: { customUrl } },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
|
||||
const team = await teamRow();
|
||||
expect(team.customTheme).toEqual(expectedStoredTheme());
|
||||
expect(team.bio).toBe("Updated bio");
|
||||
});
|
||||
});
|
||||
|
||||
describe("logo", () => {
|
||||
let imageId: number;
|
||||
|
||||
const imageExists = async (id: number) =>
|
||||
Boolean(await ImageRepository.findById(id));
|
||||
|
||||
beforeEach(async () => {
|
||||
await createTeam({ hasAvatar: true });
|
||||
|
||||
const avatarImgId = (await teamRow()).avatarImgId;
|
||||
invariant(avatarImgId, "The team was created without a logo");
|
||||
imageId = avatarImgId;
|
||||
});
|
||||
|
||||
test("deletes the submitted image row when an image is removed while editing", async () => {
|
||||
await editTeamProfileAction(
|
||||
{ ...DEFAULT_EDIT_FIELDS },
|
||||
{ user: "regular", params: { customUrl } },
|
||||
);
|
||||
|
||||
expect((await teamRow()).avatarImgId).toBeNull();
|
||||
expect(await imageExists(imageId)).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps the submitted image row when an existing image is unchanged", async () => {
|
||||
await editTeamProfileAction(
|
||||
{
|
||||
...DEFAULT_EDIT_FIELDS,
|
||||
logo: {
|
||||
type: "EXISTING",
|
||||
imgId: imageId,
|
||||
url: "https://example.com/test-avatar.jpg",
|
||||
},
|
||||
},
|
||||
{ user: "regular", params: { customUrl } },
|
||||
);
|
||||
|
||||
expect((await teamRow()).avatarImgId).toBe(imageId);
|
||||
expect(await imageExists(imageId)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { ActionFunction } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { parseFormDataWithImages } from "~/form/parse.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import { clampThemeToGamut } from "~/utils/oklch-gamut";
|
||||
import { errorToastIfFalsy, notFoundIfNullish } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { mySlugify, teamPage } from "~/utils/urls";
|
||||
import * as TeamRepository from "../TeamRepository.server";
|
||||
import { editTeamActionSchema } from "../team-schemas";
|
||||
import { teamParamsSchema } from "../team-schemas.server";
|
||||
import { canAddCustomizedColors } from "../team-utils";
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
requireUser();
|
||||
const { customUrl } = teamParamsSchema.parse(params);
|
||||
|
||||
const team = notFoundIfNullish(
|
||||
await TeamRepository.findByCustomUrl(customUrl),
|
||||
);
|
||||
|
||||
requirePermission(team, "EDIT");
|
||||
|
||||
const result = await parseFormDataWithImages({
|
||||
request,
|
||||
schema: editTeamActionSchema,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
|
||||
switch (data._action) {
|
||||
case "UPDATE_CUSTOM_THEME": {
|
||||
errorToastIfFalsy(
|
||||
canAddCustomizedColors(team),
|
||||
"Team does not have custom theme access",
|
||||
);
|
||||
|
||||
await TeamRepository.updateCustomTheme({
|
||||
id: team.id,
|
||||
customTheme: data.newValue ? clampThemeToGamut(data.newValue) : null,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
case "UPDATE_MAP_MODE_PREFERENCES": {
|
||||
await TeamRepository.updateMapModePreferences({
|
||||
id: team.id,
|
||||
mapModePreferences: data.mapModePreferences,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
case "REMOVE_MAP_MODE_PREFERENCES": {
|
||||
await TeamRepository.updateMapModePreferences({
|
||||
id: team.id,
|
||||
mapModePreferences: null,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
case "EDIT": {
|
||||
const newCustomUrl = mySlugify(data.name);
|
||||
const teams = await TeamRepository.findAllUndisbanded();
|
||||
const duplicateTeam = teams.find(
|
||||
(t) => t.customUrl === newCustomUrl && t.customUrl !== team.customUrl,
|
||||
);
|
||||
|
||||
if (duplicateTeam) {
|
||||
return { fieldErrors: { name: "forms:errors.duplicateName" } };
|
||||
}
|
||||
|
||||
const updatedTeam = await TeamRepository.update({
|
||||
id: team.id,
|
||||
name: data.name,
|
||||
bio: data.bio,
|
||||
bsky: data.bsky,
|
||||
tag: data.tag,
|
||||
avatarImgId: data.logo,
|
||||
bannerImgId: data.banner,
|
||||
});
|
||||
|
||||
throw redirect(teamPage(updatedTeam.customUrl));
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ActionFunction } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
notFoundIfNullish,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import * as TeamRepository from "../TeamRepository.server";
|
||||
import { teamProfilePageActionSchema } from "../team-schemas";
|
||||
import { teamParamsSchema } from "../team-schemas.server";
|
||||
import { isTeamMember, isTeamOwner, resolveNewOwner } from "../team-utils";
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = requireUser();
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: teamProfilePageActionSchema,
|
||||
});
|
||||
|
||||
const { customUrl } = teamParamsSchema.parse(params);
|
||||
const team = notFoundIfNullish(
|
||||
await TeamRepository.findByCustomUrl(customUrl),
|
||||
);
|
||||
|
||||
switch (data._action) {
|
||||
case "LEAVE_TEAM": {
|
||||
errorToastIfFalsy(
|
||||
isTeamMember({ user, team }),
|
||||
"You are not a member of this team",
|
||||
);
|
||||
|
||||
const newOwner = isTeamOwner({ user, team })
|
||||
? resolveNewOwner(team.members)
|
||||
: null;
|
||||
errorToastIfFalsy(
|
||||
!isTeamOwner({ user, team }) || newOwner,
|
||||
"You can't leave the team if you are the owner and there is no other member to become the owner",
|
||||
);
|
||||
|
||||
await TeamRepository.handleMemberLeaving({
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
newOwnerUserId: newOwner?.id,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "MAKE_MAIN_TEAM": {
|
||||
await TeamRepository.switchOwnMainTeam(team.id);
|
||||
|
||||
break;
|
||||
}
|
||||
case "DELETE_TEAM": {
|
||||
requirePermission(team, "DELETE");
|
||||
|
||||
await TeamRepository.deleteById(team.id);
|
||||
throw redirect("/");
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { type ActionFunction, redirect } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { errorToastIfFalsy, notFoundIfNullish } from "~/utils/remix.server";
|
||||
import { teamPage } from "~/utils/urls";
|
||||
import { validateInviteCode } from "../loaders/t.$customUrl.join.server";
|
||||
import * as TeamRepository from "../TeamRepository.server";
|
||||
import { TEAM } from "../team-constants";
|
||||
import { teamParamsSchema } from "../team-schemas.server";
|
||||
import { teamJoinSearchParams } from "../team-search-params";
|
||||
|
||||
export const action: ActionFunction = async ({ params, url }) => {
|
||||
const user = requireUser();
|
||||
const { customUrl } = teamParamsSchema.parse(params);
|
||||
|
||||
const team = notFoundIfNullish(
|
||||
await TeamRepository.findByCustomUrl(customUrl, {
|
||||
includeInviteCode: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const { code } = teamJoinSearchParams.parse(url);
|
||||
const realInviteCode = team.inviteCode!;
|
||||
|
||||
errorToastIfFalsy(
|
||||
validateInviteCode({
|
||||
inviteCode: code ?? "",
|
||||
realInviteCode,
|
||||
team,
|
||||
user,
|
||||
reachedTeamCountLimit: false, // checked in the DB transaction
|
||||
}) === "VALID",
|
||||
"Invite code is invalid",
|
||||
);
|
||||
|
||||
await TeamRepository.insertOwnMembership({
|
||||
maxTeamsAllowed:
|
||||
user.patronTier && user.patronTier >= 2
|
||||
? TEAM.MAX_TEAM_COUNT_PATRON
|
||||
: TEAM.MAX_TEAM_COUNT_NON_PATRON,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
throw redirect(teamPage(team.customUrl));
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ActionFunction } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import type {
|
||||
MemberRole,
|
||||
MemberRoleType,
|
||||
} from "~/features/team/team-constants";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import { errorToastIfFalsy, notFoundIfNullish } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { teamPage } from "~/utils/urls";
|
||||
import * as TeamRepository from "../TeamRepository.server";
|
||||
import { CUSTOM_ROLE_VALUE } from "../team-schemas";
|
||||
import { manageRosterSchema, teamParamsSchema } from "../team-schemas.server";
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = requireUser();
|
||||
|
||||
const { customUrl } = teamParamsSchema.parse(params);
|
||||
const team = notFoundIfNullish(
|
||||
await TeamRepository.findByCustomUrl(customUrl),
|
||||
);
|
||||
requirePermission(team, "MANAGE_ROSTER");
|
||||
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
schema: manageRosterSchema,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
const data = result.data;
|
||||
|
||||
switch (data._action) {
|
||||
case "RESET_INVITE_LINK": {
|
||||
await TeamRepository.resetInviteCode(team.id);
|
||||
|
||||
break;
|
||||
}
|
||||
case "UPDATE_ROSTER": {
|
||||
const submittedIds = new Set(data.members.map((m) => m.userId));
|
||||
const existingMembersById = new Map(
|
||||
team.members.map((member) => [member.id, member]),
|
||||
);
|
||||
|
||||
for (const member of data.members) {
|
||||
errorToastIfFalsy(
|
||||
existingMembersById.has(member.userId),
|
||||
"Member not found",
|
||||
);
|
||||
}
|
||||
|
||||
const kickedUserIds = team.members
|
||||
.filter((member) => !submittedIds.has(member.id))
|
||||
.map((member) => {
|
||||
errorToastIfFalsy(!member.isOwner, "Can't kick the owner");
|
||||
errorToastIfFalsy(member.id !== user.id, "Can't kick yourself");
|
||||
return member.id;
|
||||
});
|
||||
|
||||
await TeamRepository.updateRoster({
|
||||
teamId: team.id,
|
||||
members: data.members.map((member, index) => {
|
||||
const isCustom = member.role === CUSTOM_ROLE_VALUE;
|
||||
const existing = existingMembersById.get(member.userId);
|
||||
const isProtectedMember = Boolean(
|
||||
existing?.isOwner || existing?.id === user.id,
|
||||
);
|
||||
|
||||
return {
|
||||
userId: member.userId,
|
||||
role: isCustom
|
||||
? null
|
||||
: ((member.role || null) as MemberRole | null),
|
||||
customRole: isCustom ? member.customRole || null : null,
|
||||
roleType: isCustom
|
||||
? ((member.roleType || null) as MemberRoleType | null)
|
||||
: null,
|
||||
isManager: isProtectedMember
|
||||
? Boolean(existing?.isManager)
|
||||
: member.isManager,
|
||||
order: index,
|
||||
};
|
||||
}),
|
||||
kickedUserIds,
|
||||
});
|
||||
|
||||
return redirect(teamPage(customUrl));
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { wrappedAction } from "~/utils/Test";
|
||||
import { action as teamIndexPageAction } from "../actions/t.new.server";
|
||||
import type { createTeamSchema } from "../team-schemas";
|
||||
|
||||
const action = wrappedAction<typeof createTeamSchema>({
|
||||
action: teamIndexPageAction,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
|
||||
describe("team creation", () => {
|
||||
beforeEach(async () => {
|
||||
await UserFactory.createRegular();
|
||||
});
|
||||
|
||||
test("prevents creating a team with a duplicate name", async () => {
|
||||
await action({ name: "Team 1" }, { user: "regular" });
|
||||
const res = await action({ name: "Team 1" }, { user: "regular" });
|
||||
|
||||
expect(res.fieldErrors.name).toBe("forms:errors.duplicateName");
|
||||
});
|
||||
|
||||
test("prevents creating a team whose name is only special characters", async () => {
|
||||
const res = await action({ name: "𝓢𝓲𝓵" }, { user: "regular" });
|
||||
|
||||
expect(res.fieldErrors.name).toBe("forms:errors.noOnlySpecialCharacters");
|
||||
});
|
||||
});
|
||||
45
apps/web-react/app/features/team/actions/t.new.server.ts
Normal file
45
apps/web-react/app/features/team/actions/t.new.server.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ActionFunction } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { errorToastIfFalsy } from "~/utils/remix.server";
|
||||
import { teamPage } from "~/utils/urls";
|
||||
import * as TeamRepository from "../TeamRepository.server";
|
||||
import { TEAM } from "../team-constants";
|
||||
import { createTeamSchemaServer } from "../team-schemas.server";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = requireUser();
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
schema: createTeamSchemaServer,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
|
||||
const teams = await TeamRepository.findAllUndisbanded();
|
||||
|
||||
const currentTeamCount = teams.filter((team) =>
|
||||
team.members.some((m) => m.id === user.id),
|
||||
).length;
|
||||
const maxTeamCount = user.roles.includes("SUPPORTER")
|
||||
? TEAM.MAX_TEAM_COUNT_PATRON
|
||||
: TEAM.MAX_TEAM_COUNT_NON_PATRON;
|
||||
|
||||
errorToastIfFalsy(
|
||||
currentTeamCount < maxTeamCount,
|
||||
"Already in max amount of teams",
|
||||
);
|
||||
|
||||
const team = await TeamRepository.insert({
|
||||
ownerUserId: user.id,
|
||||
name: data.name,
|
||||
isMainTeam: currentTeamCount === 0,
|
||||
});
|
||||
|
||||
throw redirect(teamPage(team.customUrl));
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMatches } from "react-router";
|
||||
import { LinkButton } from "~/components/elements/Button";
|
||||
import type { TeamLoaderData } from "~/features/team/loaders/t.$customUrl.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { teamPage } from "~/utils/urls";
|
||||
|
||||
export function TeamGoBackButton() {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
const [, parentRoute] = useMatches();
|
||||
invariant(parentRoute);
|
||||
const layoutData = parentRoute.loaderData as TeamLoaderData;
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<LinkButton
|
||||
to={teamPage(layoutData.team.customUrl)}
|
||||
icon={<ChevronLeft />}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
className="mr-auto"
|
||||
>
|
||||
{t("common:actions.back")}
|
||||
</LinkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.players {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
gap: var(--s-3);
|
||||
list-style: none;
|
||||
}
|
||||
108
apps/web-react/app/features/team/components/TeamResultsTable.tsx
Normal file
108
apps/web-react/app/features/team/components/TeamResultsTable.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Users } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
import { Placement } from "~/components/Placement";
|
||||
import { Table } from "~/components/Table";
|
||||
import { TierPill } from "~/components/TierPill";
|
||||
import { UserLink } from "~/components/UserLink";
|
||||
import type { TeamResultsLoaderData } from "~/features/team/loaders/t.$customUrl.results.server";
|
||||
import { tournamentTeamPage } from "~/utils/urls";
|
||||
|
||||
import styles from "./TeamResultsTable.module.css";
|
||||
|
||||
interface TeamResultsTableProps {
|
||||
results: TeamResultsLoaderData["results"];
|
||||
}
|
||||
|
||||
export function TeamResultsTable({ results }: TeamResultsTableProps) {
|
||||
const { t } = useTranslation("user");
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("results.placing")}</th>
|
||||
<th>{t("results.date")}</th>
|
||||
<th>{t("results.tournament")}</th>
|
||||
<th>{t("results.subs")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((result) => {
|
||||
return (
|
||||
<tr key={result.tournamentId}>
|
||||
<td className="pl-4 whitespace-nowrap">
|
||||
<div className="stack horizontal xs items-end">
|
||||
<Placement placement={result.placement} />{" "}
|
||||
<div className="text-lighter">
|
||||
/ {result.participantCount}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap">
|
||||
<LocaleTime
|
||||
date={result.startsAt}
|
||||
options={{
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="stack horizontal xs items-center">
|
||||
{result.logoUrl ? (
|
||||
<img
|
||||
src={result.logoUrl}
|
||||
alt=""
|
||||
width={18}
|
||||
height={18}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : null}
|
||||
<Link
|
||||
to={tournamentTeamPage({
|
||||
tournamentTeamId: result.tournamentTeamId,
|
||||
tournamentId: result.tournamentId,
|
||||
})}
|
||||
>
|
||||
{result.tournamentName}
|
||||
</Link>
|
||||
{result.tier ? <TierPill tier={result.tier} /> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{result.subs.length > 0 ? (
|
||||
<div className="stack horizontal md items-center">
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton
|
||||
icon={<Users />}
|
||||
size="small"
|
||||
variant="minimal"
|
||||
>
|
||||
{result.subs.length}
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
<ul className={styles.players}>
|
||||
{result.subs.map((player) => (
|
||||
<li key={player.id} className="flex items-center">
|
||||
<UserLink user={player} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SendouPopover>
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { hasPermission } from "~/modules/permissions/utils";
|
||||
import { notFoundIfNullish } from "~/utils/remix.server";
|
||||
import { teamPage } from "~/utils/urls";
|
||||
import * as TeamRepository from "../TeamRepository.server";
|
||||
import { teamParamsSchema } from "../team-schemas.server";
|
||||
import { canAddCustomizedColors } from "../team-utils";
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const user = requireUser();
|
||||
const { customUrl } = teamParamsSchema.parse(params);
|
||||
|
||||
const team = notFoundIfNullish(
|
||||
await TeamRepository.findByCustomUrl(customUrl, {
|
||||
includeUnvalidatedImages: true,
|
||||
includeMapModePreferences: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!hasPermission(team, "EDIT", user)) {
|
||||
throw redirect(teamPage(customUrl));
|
||||
}
|
||||
|
||||
return {
|
||||
team,
|
||||
customTheme: canAddCustomizedColors(team) ? team.customTheme : null,
|
||||
canAddCustomizedColors: canAddCustomizedColors(team),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { SHORT_NANOID_LENGTH } from "~/utils/id";
|
||||
import { notFoundIfNullish } from "~/utils/remix.server";
|
||||
import { teamPage } from "~/utils/urls";
|
||||
import * as TeamRepository from "../TeamRepository.server";
|
||||
import { TEAM } from "../team-constants";
|
||||
import { teamParamsSchema } from "../team-schemas.server";
|
||||
import { teamJoinSearchParams } from "../team-search-params";
|
||||
import { isTeamFull, isTeamMember } from "../team-utils";
|
||||
|
||||
export const loader = async ({ params, url }: LoaderFunctionArgs) => {
|
||||
const user = requireUser();
|
||||
const { customUrl } = teamParamsSchema.parse(params);
|
||||
|
||||
const team = notFoundIfNullish(
|
||||
await TeamRepository.findByCustomUrl(customUrl, {
|
||||
includeInviteCode: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const { code } = teamJoinSearchParams.parse(url);
|
||||
const realInviteCode = team.inviteCode!;
|
||||
|
||||
const teamCount = (await TeamRepository.findAllByMemberUserId(user.id))
|
||||
.length;
|
||||
|
||||
const validation = validateInviteCode({
|
||||
inviteCode: code ?? "",
|
||||
realInviteCode,
|
||||
team,
|
||||
user,
|
||||
reachedTeamCountLimit:
|
||||
user.patronTier && user.patronTier >= 2
|
||||
? teamCount >= TEAM.MAX_TEAM_COUNT_PATRON
|
||||
: teamCount >= TEAM.MAX_TEAM_COUNT_NON_PATRON,
|
||||
});
|
||||
|
||||
if (validation === "ALREADY_JOINED") {
|
||||
throw redirect(teamPage(team.customUrl));
|
||||
}
|
||||
|
||||
return {
|
||||
validation,
|
||||
teamName: team.name,
|
||||
};
|
||||
};
|
||||
|
||||
export function validateInviteCode({
|
||||
inviteCode,
|
||||
realInviteCode,
|
||||
team,
|
||||
user,
|
||||
reachedTeamCountLimit,
|
||||
}: {
|
||||
inviteCode: string;
|
||||
realInviteCode: string;
|
||||
team: TeamRepository.findByCustomUrl;
|
||||
user?: { id: number; team?: { name: string } };
|
||||
reachedTeamCountLimit: boolean;
|
||||
}) {
|
||||
if (inviteCode.length !== SHORT_NANOID_LENGTH) {
|
||||
return "SHORT_CODE";
|
||||
}
|
||||
if (inviteCode !== realInviteCode) {
|
||||
return "INVITE_CODE_WRONG";
|
||||
}
|
||||
if (isTeamFull(team)) {
|
||||
return "TEAM_FULL";
|
||||
}
|
||||
if (isTeamMember({ team, user })) {
|
||||
return "ALREADY_JOINED";
|
||||
}
|
||||
if (reachedTeamCountLimit) {
|
||||
return "REACHED_TEAM_COUNT_LIMIT";
|
||||
}
|
||||
|
||||
return "VALID";
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { notFoundIfNullish } from "~/utils/remix.server";
|
||||
import * as TeamRepository from "../TeamRepository.server";
|
||||
import { teamParamsSchema } from "../team-schemas.server";
|
||||
|
||||
export type TeamResultsLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const { customUrl } = teamParamsSchema.parse(params);
|
||||
|
||||
const team = notFoundIfNullish(
|
||||
await TeamRepository.findByCustomUrl(customUrl),
|
||||
);
|
||||
|
||||
const results = await TeamRepository.findResultsById(team.id);
|
||||
|
||||
return {
|
||||
results,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import { notFoundIfNullish } from "~/utils/remix.server";
|
||||
import * as TeamRepository from "../TeamRepository.server";
|
||||
import { teamParamsSchema } from "../team-schemas.server";
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const { customUrl } = teamParamsSchema.parse(params);
|
||||
|
||||
const team = notFoundIfNullish(
|
||||
await TeamRepository.findByCustomUrl(customUrl, {
|
||||
includeInviteCode: true,
|
||||
}),
|
||||
);
|
||||
|
||||
requirePermission(team, "MANAGE_ROSTER");
|
||||
|
||||
return {
|
||||
team,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { notFoundIfNullish } from "~/utils/remix.server";
|
||||
import * as TeamRepository from "../TeamRepository.server";
|
||||
import { teamParamsSchema } from "../team-schemas.server";
|
||||
import { canAddCustomizedColors } from "../team-utils";
|
||||
|
||||
export type TeamLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const { customUrl } = teamParamsSchema.parse(params);
|
||||
|
||||
const team = notFoundIfNullish(
|
||||
await TeamRepository.findByCustomUrl(customUrl),
|
||||
);
|
||||
|
||||
const results = await TeamRepository.findResultPlacementsById(team.id);
|
||||
|
||||
return {
|
||||
team,
|
||||
customTheme: canAddCustomizedColors(team) ? team.customTheme : null,
|
||||
results: resultsMapped(results),
|
||||
};
|
||||
};
|
||||
|
||||
function resultsMapped(results: TeamRepository.FindResultPlacementsById) {
|
||||
if (results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstPlaceResults = results.filter((result) => result.placement === 1);
|
||||
const secondPlaceResults = results.filter((result) => result.placement === 2);
|
||||
const thirdPlaceResults = results.filter((result) => result.placement === 3);
|
||||
|
||||
return {
|
||||
count: results.length,
|
||||
placements: [
|
||||
{
|
||||
placement: 1,
|
||||
count: firstPlaceResults.length,
|
||||
},
|
||||
{
|
||||
placement: 2,
|
||||
count: secondPlaceResults.length,
|
||||
},
|
||||
{
|
||||
placement: 3,
|
||||
count: thirdPlaceResults.length,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
13
apps/web-react/app/features/team/loaders/t.new.server.ts
Normal file
13
apps/web-react/app/features/team/loaders/t.new.server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as TeamRepository from "../TeamRepository.server";
|
||||
|
||||
export const loader = async () => {
|
||||
const user = requireUser();
|
||||
|
||||
const teams = await TeamRepository.findAllUndisbanded();
|
||||
const teamMemberOfCount = teams.filter((team) =>
|
||||
team.members.some((m) => m.id === user.id),
|
||||
).length;
|
||||
|
||||
return { teamMemberOfCount };
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { wrappedAction } from "~/utils/Test";
|
||||
import { action as _editTeamAction } from "../routes/t.$customUrl.edit";
|
||||
import type { editTeamFormSchema } from "../team-schemas";
|
||||
import { createTeamOwnedByRegular } from "../tests/fixtures";
|
||||
|
||||
const editTeamAction = wrappedAction<typeof editTeamFormSchema>({
|
||||
action: _editTeamAction,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
|
||||
const DEFAULT_FIELDS = {
|
||||
tag: null,
|
||||
bsky: null,
|
||||
bio: null,
|
||||
logo: null,
|
||||
banner: null,
|
||||
} as any;
|
||||
|
||||
describe("team name editing", () => {
|
||||
beforeEach(async () => {
|
||||
await UserFactory.createRegular();
|
||||
});
|
||||
|
||||
test("can't take another team's name via editing", async () => {
|
||||
const team = await createTeamOwnedByRegular("Team 1");
|
||||
await createTeamOwnedByRegular("Team 2", false);
|
||||
|
||||
const res = await editTeamAction(
|
||||
{
|
||||
_action: "EDIT",
|
||||
name: "Team 2",
|
||||
...DEFAULT_FIELDS,
|
||||
},
|
||||
{ user: "regular", params: { customUrl: team.customUrl } },
|
||||
);
|
||||
|
||||
expect(res.fieldErrors.name).toBe("forms:errors.duplicateName");
|
||||
});
|
||||
|
||||
test("prevents editing team name to only special characters", async () => {
|
||||
const team = await createTeamOwnedByRegular("Team 1");
|
||||
|
||||
const res = await editTeamAction(
|
||||
{
|
||||
_action: "EDIT",
|
||||
name: "𝓢𝓲𝓵",
|
||||
...DEFAULT_FIELDS,
|
||||
},
|
||||
{ user: "regular", params: { customUrl: team.customUrl } },
|
||||
);
|
||||
|
||||
expect(res.fieldErrors.name).toBe("forms:errors.noOnlySpecialCharacters");
|
||||
});
|
||||
});
|
||||
173
apps/web-react/app/features/team/routes/t.$customUrl.edit.tsx
Normal file
173
apps/web-react/app/features/team/routes/t.$customUrl.edit.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MetaFunction } from "react-router";
|
||||
import { useLoaderData } from "react-router";
|
||||
import { ActionButton } from "~/components/ActionButton";
|
||||
import { CustomThemeSelector } from "~/components/CustomThemeSelector";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { Main, mainStyles } from "~/components/Main";
|
||||
import type { UserMapModePreferences } from "~/db/tables-json";
|
||||
import {
|
||||
MapModePreferencesField,
|
||||
preferencesFromRaw,
|
||||
} from "~/features/settings/components/MapModePreferencesField";
|
||||
import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton";
|
||||
import { existingImage } from "~/form/image-field";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { useActionSubmit } from "~/hooks/useActionSubmit";
|
||||
import type { ThemeInput } from "~/utils/oklch-gamut";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { action } from "../actions/t.$customUrl.edit.server";
|
||||
import { loader } from "../loaders/t.$customUrl.edit.server";
|
||||
import styles from "../team.module.css";
|
||||
import {
|
||||
editTeamActionSchema,
|
||||
editTeamFormSchema,
|
||||
updateTeamCustomThemeSchema,
|
||||
updateTeamMapModePreferencesSchema,
|
||||
} from "../team-schemas";
|
||||
|
||||
export { action, loader };
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["settings"],
|
||||
};
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
return metaTags({
|
||||
title: "Editing team",
|
||||
location: args.location,
|
||||
});
|
||||
};
|
||||
|
||||
export default function EditTeamPage() {
|
||||
const { t } = useTranslation(["common", "team"]);
|
||||
const { team, canAddCustomizedColors } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<Main className="stack lg">
|
||||
<TeamGoBackButton />
|
||||
<div className={mainStyles.narrow}>
|
||||
<SendouForm
|
||||
schema={editTeamFormSchema}
|
||||
title={t("team:editTeam.header", { teamName: team.name })}
|
||||
defaultValues={{
|
||||
name: team.name,
|
||||
tag: team.tag ?? "",
|
||||
bsky: team.bsky ?? "",
|
||||
bio: team.bio ?? "",
|
||||
logo: existingImage(team.avatarImgId, team.avatarUrl),
|
||||
banner: existingImage(team.bannerImgId, team.bannerUrl),
|
||||
}}
|
||||
submitButtonText={t("common:actions.submit")}
|
||||
submitButtonTestId="edit-team-submit-button"
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="name" />
|
||||
<FormField name="tag" />
|
||||
<FormField name="bsky" />
|
||||
<FormField name="bio" />
|
||||
<FormField name="logo" />
|
||||
<FormField name="banner" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
<Divider className={styles.mapPreferencesDivider} smallText>
|
||||
{t("team:mapPreferences.header")}
|
||||
</Divider>
|
||||
<TeamMapModePreferences />
|
||||
{canAddCustomizedColors ? (
|
||||
<>
|
||||
<Divider className={styles.formDivider} smallText>
|
||||
{t("team:forms.customTheme.header")}
|
||||
</Divider>
|
||||
<TeamCustomThemeSelector />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamMapModePreferences() {
|
||||
const { t } = useTranslation(["common", "team"]);
|
||||
const { team } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
{team.mapModePreferences ? (
|
||||
<ActionButton
|
||||
schema={editTeamActionSchema}
|
||||
action="REMOVE_MAP_MODE_PREFERENCES"
|
||||
variant="minimal-destructive"
|
||||
size="small"
|
||||
icon={<Trash2 />}
|
||||
className="mx-auto"
|
||||
aria-label="Remove team map preferences"
|
||||
confirm={{
|
||||
dialogHeading: t("team:mapPreferences.remove.header", {
|
||||
teamName: team.name,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{t("common:actions.remove")}
|
||||
</ActionButton>
|
||||
) : null}
|
||||
<div className="text-lighter text-sm text-center">
|
||||
{t("team:mapPreferences.explanation")}
|
||||
</div>
|
||||
<SendouForm
|
||||
key={JSON.stringify(team.mapModePreferences ?? null)}
|
||||
schema={updateTeamMapModePreferencesSchema}
|
||||
defaultValues={{
|
||||
mapModePreferences: preferencesFromRaw(team.mapModePreferences),
|
||||
}}
|
||||
submitButtonText={t("common:actions.save")}
|
||||
submitButtonTestId="team-map-preferences-submit-button"
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<FormField name="mapModePreferences">
|
||||
{(props: {
|
||||
value: unknown;
|
||||
onChange: (value: UserMapModePreferences) => void;
|
||||
}) => (
|
||||
<MapModePreferencesField
|
||||
value={props.value as UserMapModePreferences}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
</SendouForm>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamCustomThemeSelector() {
|
||||
const { customTheme, canAddCustomizedColors } =
|
||||
useLoaderData<typeof loader>();
|
||||
const { submit, state } = useActionSubmit(updateTeamCustomThemeSchema, {
|
||||
encType: "application/json",
|
||||
});
|
||||
|
||||
const handleSave = (themeInput: ThemeInput) => {
|
||||
submit("UPDATE_CUSTOM_THEME", { newValue: themeInput });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
submit("UPDATE_CUSTOM_THEME", { newValue: null });
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomThemeSelector
|
||||
initialTheme={customTheme}
|
||||
isSupporter={canAddCustomizedColors}
|
||||
isPersonalTheme={false}
|
||||
onSave={handleSave}
|
||||
onReset={handleReset}
|
||||
fetcherState={state}
|
||||
/>
|
||||
);
|
||||
}
|
||||
409
apps/web-react/app/features/team/routes/t.$customUrl.index.tsx
Normal file
409
apps/web-react/app/features/team/routes/t.$customUrl.index.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
import {
|
||||
LogOut,
|
||||
Menu,
|
||||
SquarePen,
|
||||
Star,
|
||||
Swords,
|
||||
Trash2,
|
||||
Users,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useMatches } from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { LinkButton, SendouButton } from "~/components/elements/Button";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
|
||||
import {
|
||||
SendouTab,
|
||||
SendouTabList,
|
||||
SendouTabPanel,
|
||||
SendouTabs,
|
||||
} from "~/components/elements/Tabs";
|
||||
import { WeaponImage } from "~/components/Image";
|
||||
import { Placement } from "~/components/Placement";
|
||||
import { UserLink } from "~/components/UserLink";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import type { TeamLoaderData } from "~/features/team/loaders/t.$customUrl.server";
|
||||
import { useActionSubmit } from "~/hooks/useActionSubmit";
|
||||
import { useHasPermission } from "~/modules/permissions/hooks";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { editTeamPage, manageTeamRosterPage, userPage } from "~/utils/urls";
|
||||
import { action } from "../actions/t.$customUrl.index.server";
|
||||
import type * as TeamRepository from "../TeamRepository.server";
|
||||
import styles from "../team.module.css";
|
||||
import { teamProfilePageActionSchema } from "../team-schemas";
|
||||
import {
|
||||
getMemberRoleType,
|
||||
isTeamMember,
|
||||
isTeamOwner,
|
||||
resolveNewOwner,
|
||||
} from "../team-utils";
|
||||
|
||||
export { action };
|
||||
|
||||
export default function TeamIndexPage() {
|
||||
const { t } = useTranslation(["team"]);
|
||||
const [, parentRoute] = useMatches();
|
||||
invariant(parentRoute);
|
||||
const layoutData = parentRoute.loaderData as TeamLoaderData;
|
||||
|
||||
const members = layoutData.team.members;
|
||||
const playerMembers = members.filter(
|
||||
(member) => getMemberRoleType(member) !== "OTHER",
|
||||
);
|
||||
const otherMembers = members.filter(
|
||||
(member) => getMemberRoleType(member) === "OTHER",
|
||||
);
|
||||
const showSections = playerMembers.length > 0 && otherMembers.length > 0;
|
||||
|
||||
const renderMember = (member: (typeof members)[number]) => (
|
||||
<React.Fragment key={member.discordId}>
|
||||
<MemberRow member={member} number={members.indexOf(member)} />
|
||||
<MobileMemberCard member={member} />
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="stack lg">
|
||||
<ActionButtons />
|
||||
{layoutData.results ? (
|
||||
<ResultsBanner results={layoutData.results} />
|
||||
) : null}
|
||||
{layoutData.team.bio ? (
|
||||
<article data-testid="team-bio">{layoutData.team.bio}</article>
|
||||
) : null}
|
||||
{showSections ? (
|
||||
<SendouTabs>
|
||||
<SendouTabList>
|
||||
<SendouTab
|
||||
id="players"
|
||||
icon={<Swords />}
|
||||
number={playerMembers.length}
|
||||
>
|
||||
{t("team:roster.sections.players")}
|
||||
</SendouTab>
|
||||
<SendouTab
|
||||
id="other"
|
||||
icon={<Wrench />}
|
||||
number={otherMembers.length}
|
||||
>
|
||||
{t("team:roster.sections.other")}
|
||||
</SendouTab>
|
||||
</SendouTabList>
|
||||
<SendouTabPanel id="players">
|
||||
<div className="stack lg">{playerMembers.map(renderMember)}</div>
|
||||
</SendouTabPanel>
|
||||
<SendouTabPanel id="other">
|
||||
<div className="stack lg">{otherMembers.map(renderMember)}</div>
|
||||
</SendouTabPanel>
|
||||
</SendouTabs>
|
||||
) : (
|
||||
<div className="stack lg">{members.map(renderMember)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButtons() {
|
||||
const { t } = useTranslation(["team"]);
|
||||
const user = useUser();
|
||||
const [, parentRoute] = useMatches();
|
||||
invariant(parentRoute);
|
||||
const layoutData = parentRoute.loaderData as TeamLoaderData;
|
||||
const team = layoutData.team;
|
||||
const canManageRoster = useHasPermission(team, "MANAGE_ROSTER");
|
||||
const canEditTeam = useHasPermission(team, "EDIT");
|
||||
|
||||
if (!isTeamMember({ user, team }) && !canManageRoster && !canEditTeam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.actionButtons}>
|
||||
{canManageRoster ? (
|
||||
<LinkButton
|
||||
size="small"
|
||||
to={manageTeamRosterPage(team.customUrl)}
|
||||
variant="outlined"
|
||||
prefetch="intent"
|
||||
icon={<Users />}
|
||||
testId="manage-roster-button"
|
||||
>
|
||||
{t("team:actionButtons.manageRoster")}
|
||||
</LinkButton>
|
||||
) : null}
|
||||
{canEditTeam ? (
|
||||
<LinkButton
|
||||
size="small"
|
||||
to={editTeamPage(team.customUrl)}
|
||||
variant="outlined"
|
||||
prefetch="intent"
|
||||
icon={<SquarePen />}
|
||||
testId="edit-team-button"
|
||||
>
|
||||
{t("team:actionButtons.editTeam")}
|
||||
</LinkButton>
|
||||
) : null}
|
||||
<TeamActionsMenu team={team} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamActionsMenu({ team }: { team: TeamLoaderData["team"] }) {
|
||||
const { t } = useTranslation(["common", "team"]);
|
||||
const user = useUser();
|
||||
const { submit } = useActionSubmit(teamProfilePageActionSchema);
|
||||
const [confirming, setConfirming] = React.useState<"LEAVE" | "DELETE" | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const isMainTeam = team.members.some(
|
||||
(member) => user?.id === member.id && member.isMainTeam,
|
||||
);
|
||||
const showMainTeamIndicator = isTeamMember({ user, team }) && isMainTeam;
|
||||
const canMakeMainTeam = isTeamMember({ user, team }) && !isMainTeam;
|
||||
const canLeaveTeam = isTeamMember({ user, team }) && team.members.length > 1;
|
||||
const canDeleteTeam = useHasPermission(team, "DELETE");
|
||||
|
||||
if (
|
||||
!showMainTeamIndicator &&
|
||||
!canMakeMainTeam &&
|
||||
!canLeaveTeam &&
|
||||
!canDeleteTeam
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const submitAction = (
|
||||
action: "LEAVE_TEAM" | "MAKE_MAIN_TEAM" | "DELETE_TEAM",
|
||||
) => {
|
||||
submit(action);
|
||||
setConfirming(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SendouMenu
|
||||
trigger={
|
||||
<SendouButton
|
||||
size="big"
|
||||
variant="minimal"
|
||||
icon={<Menu />}
|
||||
aria-label={t("team:actionButtons.teamActions")}
|
||||
testId="team-actions-menu-button"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{showMainTeamIndicator ? (
|
||||
<SendouMenuItem
|
||||
icon={<Star />}
|
||||
isActive
|
||||
isDisabled
|
||||
data-testid="main-team-indicator"
|
||||
>
|
||||
{t("team:actionButtons.mainTeam")}
|
||||
</SendouMenuItem>
|
||||
) : null}
|
||||
{canMakeMainTeam ? (
|
||||
<SendouMenuItem
|
||||
icon={<Star />}
|
||||
onAction={() => submitAction("MAKE_MAIN_TEAM")}
|
||||
data-testid="make-main-team-button"
|
||||
>
|
||||
{t("team:actionButtons.makeMainTeam")}
|
||||
</SendouMenuItem>
|
||||
) : null}
|
||||
{canLeaveTeam ? (
|
||||
<SendouMenuItem
|
||||
icon={<LogOut />}
|
||||
onAction={() => setConfirming("LEAVE")}
|
||||
data-testid="leave-team-button"
|
||||
>
|
||||
{t("team:actionButtons.leaveTeam")}
|
||||
</SendouMenuItem>
|
||||
) : null}
|
||||
{canDeleteTeam ? (
|
||||
<SendouMenuItem
|
||||
icon={<Trash2 />}
|
||||
isDestructive
|
||||
onAction={() => setConfirming("DELETE")}
|
||||
data-testid="delete-team-button"
|
||||
>
|
||||
{t("team:actionButtons.deleteTeam")}
|
||||
</SendouMenuItem>
|
||||
) : null}
|
||||
</SendouMenu>
|
||||
<SendouDialog
|
||||
isOpen={confirming === "LEAVE"}
|
||||
onClose={() => setConfirming(null)}
|
||||
onOpenChange={() => setConfirming(null)}
|
||||
isDismissable
|
||||
>
|
||||
<ConfirmActionContent
|
||||
heading={t(
|
||||
isTeamOwner({ user, team })
|
||||
? "team:leaveTeam.header.newOwner"
|
||||
: "team:leaveTeam.header",
|
||||
{
|
||||
teamName: team.name,
|
||||
newOwner: resolveNewOwner(team.members)?.username,
|
||||
},
|
||||
)}
|
||||
buttonText={t("team:actionButtons.leaveTeam.confirm")}
|
||||
onConfirm={() => submitAction("LEAVE_TEAM")}
|
||||
/>
|
||||
</SendouDialog>
|
||||
<SendouDialog
|
||||
isOpen={confirming === "DELETE"}
|
||||
onClose={() => setConfirming(null)}
|
||||
onOpenChange={() => setConfirming(null)}
|
||||
isDismissable
|
||||
>
|
||||
<ConfirmActionContent
|
||||
heading={t("team:deleteTeam.header", { teamName: team.name })}
|
||||
buttonText={t("common:actions.delete")}
|
||||
onConfirm={() => submitAction("DELETE_TEAM")}
|
||||
/>
|
||||
</SendouDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmActionContent({
|
||||
heading,
|
||||
buttonText,
|
||||
onConfirm,
|
||||
}: {
|
||||
heading: string;
|
||||
buttonText: string;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="stack md">
|
||||
<h2 className="text-md text-center">{heading}</h2>
|
||||
<div className="stack horizontal md justify-center mt-2">
|
||||
<SendouButton
|
||||
variant="destructive"
|
||||
onPress={onConfirm}
|
||||
data-testid="confirm-button"
|
||||
>
|
||||
{buttonText}
|
||||
</SendouButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultsBanner({
|
||||
results,
|
||||
}: {
|
||||
results: NonNullable<TeamLoaderData["results"]>;
|
||||
}) {
|
||||
return (
|
||||
<Link className={styles.results} to="results">
|
||||
<div>View {results.count} results</div>
|
||||
<ul className={styles.resultsPlacements}>
|
||||
{results.placements.map(({ placement, count }) => {
|
||||
return (
|
||||
<li key={placement}>
|
||||
<Placement placement={placement} />×{count}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberRow({
|
||||
member,
|
||||
number,
|
||||
}: {
|
||||
member: TeamRepository.findByCustomUrl["members"][number];
|
||||
number: number;
|
||||
}) {
|
||||
const { t } = useTranslation(["team"]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.member}
|
||||
data-testid={member.isOwner ? `member-owner-${member.id}` : undefined}
|
||||
>
|
||||
{member.customRole ? (
|
||||
<span
|
||||
className={styles.memberRole}
|
||||
data-testid={`member-row-role-${number}`}
|
||||
>
|
||||
{member.customRole}
|
||||
</span>
|
||||
) : member.role ? (
|
||||
<span
|
||||
className={styles.memberRole}
|
||||
data-testid={`member-row-role-${number}`}
|
||||
>
|
||||
{t(`team:roles.${member.role}`)}
|
||||
</span>
|
||||
) : null}
|
||||
<div className={styles.memberSection}>
|
||||
<Link
|
||||
to={userPage(member)}
|
||||
className={styles.memberAvatarNameContainer}
|
||||
>
|
||||
<div className={styles.memberAvatar}>
|
||||
<Avatar user={member} size="md" />
|
||||
</div>
|
||||
{member.username}
|
||||
</Link>
|
||||
<div className="stack horizontal md">
|
||||
{member.weapons.map((weapon) => (
|
||||
<WeaponImage
|
||||
key={weapon.weaponSplId}
|
||||
weapon={weapon}
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileMemberCard({
|
||||
member,
|
||||
}: {
|
||||
member: TeamRepository.findByCustomUrl["members"][number];
|
||||
}) {
|
||||
const { t } = useTranslation(["team"]);
|
||||
|
||||
return (
|
||||
<div className={styles.memberCardContainer}>
|
||||
<div className={styles.memberCard}>
|
||||
<UserLink user={member} size="md" direction="vertical">
|
||||
<div className={styles.memberCardName}>{member.username}</div>
|
||||
</UserLink>
|
||||
{member.weapons.length > 0 ? (
|
||||
<div className="stack horizontal md">
|
||||
{member.weapons.map((weapon) => (
|
||||
<WeaponImage
|
||||
key={weapon.weaponSplId}
|
||||
weapon={weapon}
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{member.customRole ? (
|
||||
<span className={styles.memberRoleMobile}>{member.customRole}</span>
|
||||
) : member.role ? (
|
||||
<span className={styles.memberRoleMobile}>
|
||||
{t(`team:roles.${member.role}`)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Form, useLoaderData } from "react-router";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { action } from "../actions/t.$customUrl.join.server";
|
||||
import { loader } from "../loaders/t.$customUrl.join.server";
|
||||
import styles from "../team.module.css";
|
||||
|
||||
export { action, loader };
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["team"],
|
||||
};
|
||||
|
||||
export default function JoinTeamPage() {
|
||||
const { t } = useTranslation(["team", "common"]);
|
||||
const { validation, teamName } = useLoaderData<{
|
||||
// not sure why using typeof loader here results validation in being typed as "string"
|
||||
validation:
|
||||
| "SHORT_CODE"
|
||||
| "INVITE_CODE_WRONG"
|
||||
| "TEAM_FULL"
|
||||
| "REACHED_TEAM_COUNT_LIMIT"
|
||||
| "VALID";
|
||||
teamName: string;
|
||||
}>();
|
||||
|
||||
return (
|
||||
<Main>
|
||||
<Form method="post" className={styles.inviteContainer}>
|
||||
<div className="text-center">
|
||||
{t(`team:validation.${validation}`, { teamName })}
|
||||
</div>
|
||||
{validation === "VALID" ? (
|
||||
<SubmitButton size="big">{t("common:actions.join")}</SubmitButton>
|
||||
) : null}
|
||||
</Form>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useLoaderData } from "react-router";
|
||||
import { Main } from "~/components/Main";
|
||||
import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton";
|
||||
import { TeamResultsTable } from "~/features/team/components/TeamResultsTable";
|
||||
import { loader } from "../loaders/t.$customUrl.results.server";
|
||||
|
||||
export { loader };
|
||||
|
||||
export const handle = {
|
||||
i18n: ["user"],
|
||||
};
|
||||
|
||||
export default function TeamResultsPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<Main className="stack lg">
|
||||
<TeamGoBackButton />
|
||||
<TeamResultsTable results={data.results} />
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
182
apps/web-react/app/features/team/routes/t.$customUrl.roster.tsx
Normal file
182
apps/web-react/app/features/team/routes/t.$customUrl.roster.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { Check, Clipboard } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MetaFunction } from "react-router";
|
||||
import { useLoaderData } from "react-router";
|
||||
import { ActionButton } from "~/components/ActionButton";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { Main } from "~/components/Main";
|
||||
import { Config } from "~/config";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton";
|
||||
import { joinTeamPage } from "~/features/team/team-urls";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import type { ArrayItemRenderContext } from "~/form/types";
|
||||
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import { action } from "../actions/t.$customUrl.roster.server";
|
||||
import { loader } from "../loaders/t.$customUrl.roster.server";
|
||||
import {
|
||||
CUSTOM_ROLE_VALUE,
|
||||
resetInviteLinkSchema,
|
||||
updateRosterSchema,
|
||||
} from "../team-schemas";
|
||||
import { isTeamFull } from "../team-utils";
|
||||
|
||||
export { action, loader };
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
return metaTags({
|
||||
title: "Managing team roster",
|
||||
location: args.location,
|
||||
});
|
||||
};
|
||||
|
||||
export default function ManageTeamRosterPage() {
|
||||
const { t } = useTranslation(["team"]);
|
||||
|
||||
return (
|
||||
<Main halfWidth className="stack lg">
|
||||
<TeamGoBackButton />
|
||||
<InviteCodeSection />
|
||||
<MemberActions />
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton
|
||||
className="self-start italic"
|
||||
size="small"
|
||||
variant="minimal"
|
||||
>
|
||||
{t("team:editorsInfo.button")}
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
{t("team:editorsInfo.popover")}
|
||||
</SendouPopover>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteCodeSection() {
|
||||
const { t } = useTranslation(["common", "team"]);
|
||||
const { team } = useLoaderData<typeof loader>();
|
||||
const { copyToClipboard, copySuccess } = useCopyToClipboard();
|
||||
|
||||
if (isTeamFull(team)) {
|
||||
return (
|
||||
<Alert variation="INFO" alertClassName="mx-auto w-max">
|
||||
{t("team:roster.teamFull")}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const inviteLink = `${Config.siteDomain}${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" data-testid="invite-link">
|
||||
{inviteLink}
|
||||
</div>
|
||||
<div className="stack horizontal md">
|
||||
<SendouButton
|
||||
size="small"
|
||||
icon={copySuccess ? <Check /> : <Clipboard />}
|
||||
onPress={() => copyToClipboard(inviteLink)}
|
||||
>
|
||||
{t("common:actions.copyToClipboard")}
|
||||
</SendouButton>
|
||||
<ActionButton
|
||||
schema={resetInviteLinkSchema}
|
||||
action="RESET_INVITE_LINK"
|
||||
variant="minimal-destructive"
|
||||
size="small"
|
||||
testId="reset-invite-link-button"
|
||||
>
|
||||
{t("common:actions.reset")}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberActions() {
|
||||
const { t } = useTranslation(["common", "team"]);
|
||||
const { team } = useLoaderData<typeof loader>();
|
||||
const user = useUser();
|
||||
|
||||
const isProtectedMember = (member: { id: number; isOwner: number }) =>
|
||||
Boolean(member.isOwner) || member.id === user!.id;
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<h2 className="text-lg">{t("team:roster.members.header")}</h2>
|
||||
|
||||
<SendouForm
|
||||
fullWidth
|
||||
schema={updateRosterSchema}
|
||||
submitButtonText={t("common:actions.save")}
|
||||
defaultValues={{
|
||||
members: team.members.map((member) => ({
|
||||
userId: member.id,
|
||||
role: member.customRole ? CUSTOM_ROLE_VALUE : (member.role ?? null),
|
||||
customRole: member.customRole ?? null,
|
||||
roleType: member.roleType ?? "PLAYER",
|
||||
isManager: Boolean(member.isManager),
|
||||
})),
|
||||
}}
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<FormField
|
||||
name="members"
|
||||
canRemoveItem={(item) => {
|
||||
const member = team.members.find(
|
||||
(m) => m.id === (item as { userId: number }).userId,
|
||||
);
|
||||
return Boolean(member) && !isProtectedMember(member!);
|
||||
}}
|
||||
>
|
||||
{({ index, itemName, values }: ArrayItemRenderContext) => {
|
||||
const member = team.members.find(
|
||||
(m) => m.id === (values as { userId: number }).userId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="stack md-plus"
|
||||
data-testid={`member-row-${index}`}
|
||||
>
|
||||
<div
|
||||
className="stack horizontal sm items-center text-sm font-bold mb-2"
|
||||
data-testid={`member-row-username-${index}`}
|
||||
>
|
||||
{member ? <Avatar size="xs" user={member} /> : null}
|
||||
{member?.username}
|
||||
</div>
|
||||
<FormField name={`${itemName}.role`} />
|
||||
{(values as { role: string | null }).role ===
|
||||
CUSTOM_ROLE_VALUE ? (
|
||||
<>
|
||||
<FormField name={`${itemName}.customRole`} />
|
||||
<FormField name={`${itemName}.roleType`} />
|
||||
</>
|
||||
) : null}
|
||||
{member && !isProtectedMember(member) ? (
|
||||
<FormField name={`${itemName}.isManager`} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</FormField>
|
||||
)}
|
||||
</SendouForm>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
apps/web-react/app/features/team/routes/t.$customUrl.test.ts
Normal file
171
apps/web-react/app/features/team/routes/t.$customUrl.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { REGULAR_USER_TEST_ID } from "~/db/seed/constants";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { assertResponseErrored, wrappedAction } from "~/utils/Test";
|
||||
import { action as _teamPageAction } from "../actions/t.$customUrl.index.server";
|
||||
import { action as teamIndexPageAction } from "../actions/t.new.server";
|
||||
import * as TeamRepository from "../TeamRepository.server";
|
||||
import type {
|
||||
createTeamSchema,
|
||||
teamProfilePageActionSchema,
|
||||
} from "../team-schemas";
|
||||
import {
|
||||
createTeamOwnedByRegular,
|
||||
createTeamWithRegularMember,
|
||||
} from "../tests/fixtures";
|
||||
|
||||
const createTeamAction = wrappedAction<typeof createTeamSchema>({
|
||||
action: teamIndexPageAction,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
const teamPageAction = wrappedAction<typeof teamProfilePageActionSchema>({
|
||||
action: _teamPageAction,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
|
||||
async function loadTeams() {
|
||||
const teams =
|
||||
await TeamRepository.findAllByMemberUserId(REGULAR_USER_TEST_ID);
|
||||
|
||||
const mainTeam = teams.find((t) => t.isMainTeam);
|
||||
const secondaryTeams = teams.filter((t) => !t.isMainTeam);
|
||||
|
||||
return { team: mainTeam, secondaryTeams };
|
||||
}
|
||||
|
||||
describe("Secondary teams", () => {
|
||||
beforeEach(async () => {
|
||||
await UserFactory.createAdmin();
|
||||
await UserFactory.createRegular();
|
||||
});
|
||||
|
||||
test("first team created becomes main team", async () => {
|
||||
await createTeamAction({ name: "Team 1" }, { user: "regular" });
|
||||
|
||||
const { team, secondaryTeams } = await loadTeams();
|
||||
|
||||
expect(team!.name).toBe("Team 1");
|
||||
expect(secondaryTeams).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("second team created becomes secondary", async () => {
|
||||
await createTeamAction({ name: "Team 1" }, { user: "regular" });
|
||||
await createTeamAction({ name: "Team 2" }, { user: "regular" });
|
||||
|
||||
const { team, secondaryTeams } = await loadTeams();
|
||||
|
||||
expect(team!.name).toBe("Team 1");
|
||||
expect(secondaryTeams[0].name).toBe("Team 2");
|
||||
});
|
||||
|
||||
test("makes secondary team main team", async () => {
|
||||
await createTeamAction({ name: "Team 1" }, { user: "regular" });
|
||||
await createTeamAction({ name: "Team 2" }, { user: "regular" });
|
||||
|
||||
const { team, secondaryTeams } = await loadTeams();
|
||||
|
||||
expect(team!.name).toBe("Team 1");
|
||||
expect(secondaryTeams[0].name).toBe("Team 2");
|
||||
});
|
||||
|
||||
test("sets main team (2 team)", async () => {
|
||||
await createTeamOwnedByRegular("Team 1");
|
||||
const secondary = await createTeamOwnedByRegular("Team 2", false);
|
||||
|
||||
await teamPageAction(
|
||||
{ _action: "MAKE_MAIN_TEAM" },
|
||||
{ user: "regular", params: { customUrl: secondary.customUrl } },
|
||||
);
|
||||
|
||||
const { team } = await loadTeams();
|
||||
|
||||
expect(team!.name).toBe("Team 2");
|
||||
});
|
||||
|
||||
test("when deleting the main team, the secondary team becomes main", async () => {
|
||||
const main = await createTeamOwnedByRegular("Team 1");
|
||||
await createTeamOwnedByRegular("Team 2", false);
|
||||
|
||||
await teamPageAction(
|
||||
{
|
||||
_action: "DELETE_TEAM",
|
||||
},
|
||||
{
|
||||
user: "regular",
|
||||
params: { customUrl: main.customUrl },
|
||||
},
|
||||
);
|
||||
|
||||
const { team, secondaryTeams } = await loadTeams();
|
||||
|
||||
expect(team!.name).toBe("Team 2");
|
||||
expect(secondaryTeams).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("only the team owner (or admin) can delete a team", async () => {
|
||||
const { customUrl } = await createTeamWithRegularMember({ name: "Team 1" });
|
||||
|
||||
await expect(
|
||||
teamPageAction(
|
||||
{ _action: "DELETE_TEAM" },
|
||||
{ user: "regular", params: { customUrl } },
|
||||
),
|
||||
).rejects.toThrow("Response thrown with status code: 403");
|
||||
|
||||
expect(await TeamRepository.findByCustomUrl(customUrl)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("when leaving the main team, the secondary team becomes main", async () => {
|
||||
// owned by the admin because you can't leave a team you own
|
||||
const main = await createTeamWithRegularMember({ name: "Team 1" });
|
||||
await createTeamWithRegularMember({ name: "Team 2", isMainTeam: false });
|
||||
|
||||
const { team, secondaryTeams } = await loadTeams();
|
||||
|
||||
expect(team!.name).toBe("Team 1");
|
||||
expect(secondaryTeams[0].name).toBe("Team 2");
|
||||
|
||||
await teamPageAction(
|
||||
{
|
||||
_action: "LEAVE_TEAM",
|
||||
},
|
||||
{
|
||||
user: "regular",
|
||||
params: { customUrl: main.customUrl },
|
||||
},
|
||||
);
|
||||
|
||||
const { team: newTeam, secondaryTeams: newSecondaryTeams } =
|
||||
await loadTeams();
|
||||
|
||||
expect(newTeam!.name).toBe("Team 2");
|
||||
expect(newSecondaryTeams).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("creates max 2 teams as non-patron", async () => {
|
||||
await createTeamAction({ name: "Team 1" }, { user: "regular" });
|
||||
await createTeamAction({ name: "Team 2" }, { user: "regular" });
|
||||
|
||||
const response = await createTeamAction(
|
||||
{ name: "Team 3" },
|
||||
{ user: "regular" },
|
||||
);
|
||||
|
||||
assertResponseErrored(response);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Secondary teams as patron", () => {
|
||||
beforeEach(async () => {
|
||||
await UserFactory.createRegular(null, { patronTier: 2 });
|
||||
});
|
||||
|
||||
test("creates more than 2 teams as patron", async () => {
|
||||
await createTeamAction({ name: "Team 1" }, { user: "regular" });
|
||||
await createTeamAction({ name: "Team 2" }, { user: "regular" });
|
||||
await createTeamAction({ name: "Team 3" }, { user: "regular" });
|
||||
|
||||
const { secondaryTeams } = await loadTeams();
|
||||
expect(secondaryTeams).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
159
apps/web-react/app/features/team/routes/t.$customUrl.tsx
Normal file
159
apps/web-react/app/features/team/routes/t.$customUrl.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import clsx from "clsx";
|
||||
import type { MetaFunction } from "react-router";
|
||||
import { Outlet, useLoaderData } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import { Flag } from "~/components/Flag";
|
||||
import { BskyIcon } from "~/components/icons/Bsky";
|
||||
import { Main } from "~/components/Main";
|
||||
import { metaTags, type SerializeFrom } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { bskyUrl, navIconUrl, TEAM_SEARCH_PAGE, teamPage } from "~/utils/urls";
|
||||
import { loader } from "../loaders/t.$customUrl.server";
|
||||
|
||||
export { loader };
|
||||
|
||||
import styles from "../team.module.css";
|
||||
|
||||
export const meta: MetaFunction<typeof loader> = (args) => {
|
||||
if (!args.loaderData) return [];
|
||||
|
||||
return metaTags({
|
||||
title: args.loaderData.team.name,
|
||||
description: args.loaderData.team.bio ?? undefined,
|
||||
location: args.location,
|
||||
image: args.loaderData.team.avatarUrl
|
||||
? {
|
||||
url: args.loaderData.team.avatarUrl,
|
||||
dimensions: {
|
||||
width: 124,
|
||||
height: 124,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["team"],
|
||||
breadcrumb: ({ match }) => {
|
||||
const data = match.loaderData as SerializeFrom<typeof loader> | undefined;
|
||||
|
||||
if (!data) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
imgPath: navIconUrl("t"),
|
||||
href: TEAM_SEARCH_PAGE,
|
||||
type: "IMAGE",
|
||||
},
|
||||
{
|
||||
text: data.team.name,
|
||||
href: teamPage(data.team.customUrl),
|
||||
type: "TEXT",
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default function TeamPage() {
|
||||
return (
|
||||
<Main className="stack sm">
|
||||
<div className="stack sm">
|
||||
<TeamBanner />
|
||||
</div>
|
||||
<MobileTeamNameCountry />
|
||||
<Outlet />
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamBanner() {
|
||||
const { team } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.banner,
|
||||
!team.bannerUrl && styles.bannerPlaceholder,
|
||||
)}
|
||||
style={{
|
||||
"--team-banner-img": team.bannerUrl
|
||||
? `url("${team.bannerUrl}")`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{team.avatarUrl ? (
|
||||
<div className={styles.bannerAvatar}>
|
||||
<div>
|
||||
<img src={team.avatarUrl} alt="" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.bannerFlags}>
|
||||
{R.unique(
|
||||
team.members
|
||||
.map((member) => member.country)
|
||||
.filter((country) => country !== null),
|
||||
).map((country) => {
|
||||
return <Flag key={country} countryCode={country} />;
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.bannerName}>
|
||||
{team.tag ? (
|
||||
<div className={`${styles.bannerTag} ${styles.bannerTagDesktop}`}>
|
||||
{team.tag}
|
||||
</div>
|
||||
) : null}
|
||||
{team.name} <BskyLink />
|
||||
</div>
|
||||
</div>
|
||||
{team.avatarUrl ? <div className={styles.bannerAvatarSpacer} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileTeamNameCountry() {
|
||||
const { team } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className={styles.mobileNameCountry}>
|
||||
<div className="stack horizontal sm">
|
||||
{R.unique(
|
||||
team.members
|
||||
.map((member) => member.country)
|
||||
.filter((country) => country !== null),
|
||||
).map((country) => {
|
||||
return <Flag key={country} countryCode={country} tiny />;
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.mobileTeamName}>
|
||||
{team.name}
|
||||
<BskyLink />
|
||||
</div>
|
||||
{team.tag ? (
|
||||
<div className={`${styles.bannerTag} ${styles.bannerTagMobile}`}>
|
||||
{team.tag}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BskyLink() {
|
||||
const { team } = useLoaderData<typeof loader>();
|
||||
|
||||
if (!team.bsky) return null;
|
||||
|
||||
return (
|
||||
<a
|
||||
className={styles.bskyLink}
|
||||
data-testid="bsky-link"
|
||||
href={bskyUrl(team.bsky)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<BskyIcon />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
61
apps/web-react/app/features/team/routes/t.new.tsx
Normal file
61
apps/web-react/app/features/team/routes/t.new.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MetaFunction } from "react-router";
|
||||
import { useLoaderData } from "react-router";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SendouForm } from "~/form";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { NEW_TEAM_PAGE, navIconUrl } from "~/utils/urls";
|
||||
import { action } from "../actions/t.new.server";
|
||||
import { loader } from "../loaders/t.new.server";
|
||||
import { TEAM } from "../team-constants";
|
||||
import { createTeamSchema } from "../team-schemas";
|
||||
|
||||
export { action, loader };
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
return metaTags({
|
||||
title: "New team",
|
||||
location: args.location,
|
||||
});
|
||||
};
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["team", "forms"],
|
||||
breadcrumb: () => ({
|
||||
imgPath: navIconUrl("t"),
|
||||
href: NEW_TEAM_PAGE,
|
||||
type: "IMAGE",
|
||||
}),
|
||||
};
|
||||
|
||||
export default function NewTeamPage() {
|
||||
const { t } = useTranslation(["team"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const isSupporter = useHasRole("SUPPORTER");
|
||||
|
||||
const canAddNewTeam = () => {
|
||||
if (isSupporter) {
|
||||
return data.teamMemberOfCount < TEAM.MAX_TEAM_COUNT_PATRON;
|
||||
}
|
||||
|
||||
return data.teamMemberOfCount < TEAM.MAX_TEAM_COUNT_NON_PATRON;
|
||||
};
|
||||
|
||||
return (
|
||||
<Main className="stack lg half-width">
|
||||
{!canAddNewTeam() ? (
|
||||
<Alert variation="WARNING">
|
||||
You can't add another team (max 2 for non-supporters and 5 for
|
||||
supporters).
|
||||
</Alert>
|
||||
) : (
|
||||
<SendouForm schema={createTeamSchema} title={t("team:newTeam.header")}>
|
||||
{({ FormField }) => <FormField name="name" />}
|
||||
</SendouForm>
|
||||
)}
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
5
apps/web-react/app/features/team/routes/t.tsx
Normal file
5
apps/web-react/app/features/team/routes/t.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "react-router";
|
||||
|
||||
export const loader = () => {
|
||||
return redirect("/?search=open&type=teams");
|
||||
};
|
||||
36
apps/web-react/app/features/team/team-constants.ts
Normal file
36
apps/web-react/app/features/team/team-constants.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export const TEAM = {
|
||||
NAME_MAX_LENGTH: 64,
|
||||
NAME_MIN_LENGTH: 2,
|
||||
BIO_MAX_LENGTH: 2000,
|
||||
BSKY_MAX_LENGTH: 50,
|
||||
TAG_MAX_LENGTH: 6,
|
||||
MAX_MEMBER_COUNT: 10,
|
||||
MAX_TEAM_COUNT_NON_PATRON: 2,
|
||||
MAX_TEAM_COUNT_PATRON: 5,
|
||||
};
|
||||
|
||||
export const TEAM_MEMBER_ROLES = [
|
||||
"CAPTAIN",
|
||||
"CO_CAPTAIN",
|
||||
"FRONTLINE",
|
||||
"SLAYER",
|
||||
"SKIRMISHER",
|
||||
"SUPPORT",
|
||||
"MIDLINE",
|
||||
"BACKLINE",
|
||||
"FLEX",
|
||||
"SUB",
|
||||
"COACH",
|
||||
"CHEERLEADER",
|
||||
] as const;
|
||||
|
||||
export type MemberRole = (typeof TEAM_MEMBER_ROLES)[number];
|
||||
|
||||
/** Classifies how a team member's `customRole` should be treated. */
|
||||
export type MemberRoleType = "PLAYER" | "OTHER";
|
||||
|
||||
/** Roles that are not part of a team's active competitive lineup. Excluded when sourcing a roster (e.g. prefilling tournament registration or a scrim post). */
|
||||
export const NON_PLAYER_TEAM_ROLES: readonly (typeof TEAM_MEMBER_ROLES)[number][] =
|
||||
["CHEERLEADER", "COACH", "SUB"];
|
||||
|
||||
export const CUSTOM_ROLE_MAX_LENGTH = 32;
|
||||
28
apps/web-react/app/features/team/team-schemas.server.ts
Normal file
28
apps/web-react/app/features/team/team-schemas.server.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
import { mySlugify } from "~/utils/urls";
|
||||
import * as TeamRepository from "./TeamRepository.server";
|
||||
import {
|
||||
createTeamSchema,
|
||||
resetInviteLinkSchema,
|
||||
updateRosterSchema,
|
||||
} from "./team-schemas";
|
||||
|
||||
export const createTeamSchemaServer = z.object({
|
||||
...createTeamSchema.shape,
|
||||
name: createTeamSchema.shape.name.refine(
|
||||
async (name) => {
|
||||
const teams = await TeamRepository.findAllUndisbanded();
|
||||
const customUrl = mySlugify(name);
|
||||
|
||||
return !teams.some((team) => team.customUrl === customUrl);
|
||||
},
|
||||
{ message: "forms:errors.duplicateName" },
|
||||
),
|
||||
});
|
||||
|
||||
export const teamParamsSchema = z.object({ customUrl: z.string() });
|
||||
|
||||
export const manageRosterSchema = z.union([
|
||||
updateRosterSchema,
|
||||
resetInviteLinkSchema,
|
||||
]);
|
||||
166
apps/web-react/app/features/team/team-schemas.ts
Normal file
166
apps/web-react/app/features/team/team-schemas.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { z } from "zod";
|
||||
import type { UserMapModePreferences } from "~/db/tables-json";
|
||||
import { mapModePreferencesValueSchema } from "~/features/settings/match-profile-schemas";
|
||||
import {
|
||||
array,
|
||||
customField,
|
||||
fieldset,
|
||||
idConstant,
|
||||
image,
|
||||
select,
|
||||
selectOptional,
|
||||
stringConstant,
|
||||
textAreaOptional,
|
||||
textField,
|
||||
textFieldOptional,
|
||||
toggle,
|
||||
} from "~/form/fields";
|
||||
import { mySlugify } from "~/utils/urls";
|
||||
import { _action, themeInputSchema } from "~/utils/zod";
|
||||
import {
|
||||
CUSTOM_ROLE_MAX_LENGTH,
|
||||
TEAM,
|
||||
TEAM_MEMBER_ROLES,
|
||||
} from "./team-constants";
|
||||
|
||||
export const resetInviteLinkSchema = z.object({
|
||||
_action: _action("RESET_INVITE_LINK"),
|
||||
});
|
||||
|
||||
export const teamProfilePageActionSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("LEAVE_TEAM"),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("MAKE_MAIN_TEAM"),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("DELETE_TEAM"),
|
||||
}),
|
||||
]);
|
||||
|
||||
const teamNameValidate = {
|
||||
func: (teamName: string) =>
|
||||
mySlugify(teamName).length > 0 && mySlugify(teamName) !== "new",
|
||||
message: "forms:errors.noOnlySpecialCharacters",
|
||||
} as const;
|
||||
|
||||
export const createTeamSchema = z.object({
|
||||
name: textField({
|
||||
label: "labels.name",
|
||||
minLength: TEAM.NAME_MIN_LENGTH,
|
||||
maxLength: TEAM.NAME_MAX_LENGTH,
|
||||
validate: teamNameValidate,
|
||||
}),
|
||||
});
|
||||
|
||||
export const editTeamFormSchema = z.object({
|
||||
_action: stringConstant("EDIT"),
|
||||
name: textField({
|
||||
label: "labels.name",
|
||||
bottomText: "bottomTexts.name",
|
||||
minLength: TEAM.NAME_MIN_LENGTH,
|
||||
maxLength: TEAM.NAME_MAX_LENGTH,
|
||||
validate: teamNameValidate,
|
||||
}),
|
||||
tag: textFieldOptional({
|
||||
label: "labels.tag",
|
||||
bottomText: "bottomTexts.tag",
|
||||
maxLength: TEAM.TAG_MAX_LENGTH,
|
||||
}),
|
||||
bsky: textFieldOptional({
|
||||
label: "labels.teamBsky",
|
||||
leftAddon: "https://bsky.app/profile/",
|
||||
maxLength: TEAM.BSKY_MAX_LENGTH,
|
||||
}),
|
||||
bio: textAreaOptional({
|
||||
label: "labels.bio",
|
||||
maxLength: TEAM.BIO_MAX_LENGTH,
|
||||
}),
|
||||
logo: image({ label: "labels.logo" }),
|
||||
banner: image({ label: "labels.banner", dimensions: "thick-banner" }),
|
||||
});
|
||||
|
||||
export const updateTeamCustomThemeSchema = z.object({
|
||||
_action: _action("UPDATE_CUSTOM_THEME"),
|
||||
newValue: z.preprocess(
|
||||
(val) => (!val || val === "null" ? null : val),
|
||||
themeInputSchema.nullable(),
|
||||
),
|
||||
});
|
||||
|
||||
export const updateTeamMapModePreferencesSchema = z.object({
|
||||
_action: stringConstant("UPDATE_MAP_MODE_PREFERENCES"),
|
||||
mapModePreferences: customField(
|
||||
{ initialValue: { modes: [], pool: [] } satisfies UserMapModePreferences },
|
||||
mapModePreferencesValueSchema,
|
||||
),
|
||||
});
|
||||
|
||||
const removeTeamMapModePreferencesSchema = z.object({
|
||||
_action: _action("REMOVE_MAP_MODE_PREFERENCES"),
|
||||
});
|
||||
|
||||
/** Every payload the team edit route action accepts, discriminated by `_action`. */
|
||||
export const editTeamActionSchema = z.union([
|
||||
editTeamFormSchema,
|
||||
updateTeamCustomThemeSchema,
|
||||
updateTeamMapModePreferencesSchema,
|
||||
removeTeamMapModePreferencesSchema,
|
||||
]);
|
||||
|
||||
/** Sentinel `role` value selected to switch a member to a free-text custom role. Never stored. */
|
||||
export const CUSTOM_ROLE_VALUE = "CUSTOM";
|
||||
|
||||
export const updateRosterSchema = z
|
||||
.object({
|
||||
_action: stringConstant("UPDATE_ROSTER"),
|
||||
members: array({
|
||||
max: TEAM.MAX_MEMBER_COUNT,
|
||||
addable: false,
|
||||
sortable: true,
|
||||
field: fieldset({
|
||||
fields: z.object({
|
||||
userId: idConstant(),
|
||||
role: selectOptional({
|
||||
label: "labels.teamMemberRole",
|
||||
items: [
|
||||
...TEAM_MEMBER_ROLES.map((role) => ({
|
||||
value: role,
|
||||
label: `options.teamMemberRole.${role}` as const,
|
||||
})),
|
||||
{
|
||||
value: CUSTOM_ROLE_VALUE,
|
||||
label: "options.teamMemberRole.CUSTOM" as const,
|
||||
},
|
||||
],
|
||||
}),
|
||||
customRole: textFieldOptional({
|
||||
label: "labels.teamMemberCustomRole",
|
||||
maxLength: CUSTOM_ROLE_MAX_LENGTH,
|
||||
}),
|
||||
roleType: select({
|
||||
label: "labels.teamMemberRoleType",
|
||||
items: [
|
||||
{ value: "PLAYER", label: "options.teamMemberRoleType.PLAYER" },
|
||||
{ value: "OTHER", label: "options.teamMemberRoleType.OTHER" },
|
||||
],
|
||||
}),
|
||||
isManager: toggle({ label: "labels.teamEditor" }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
for (const [index, member] of data.members.entries()) {
|
||||
const isCustom = member.role === CUSTOM_ROLE_VALUE;
|
||||
|
||||
if (isCustom && !member.customRole) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["members", index, "customRole"],
|
||||
message: "forms:errors.customRoleRequired",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
11
apps/web-react/app/features/team/team-search-params.test.ts
Normal file
11
apps/web-react/app/features/team/team-search-params.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, test } from "vitest";
|
||||
import { assertRoundTrips } from "~/modules/search-params/search-params-test-utils";
|
||||
import { teamJoinSearchParams } from "./team-search-params";
|
||||
|
||||
describe("teamJoinSearchParams", () => {
|
||||
test("round-trips", () => {
|
||||
assertRoundTrips(teamJoinSearchParams, {
|
||||
code: ["abcd1234", "F3-9_xyz"],
|
||||
});
|
||||
});
|
||||
});
|
||||
7
apps/web-react/app/features/team/team-search-params.ts
Normal file
7
apps/web-react/app/features/team/team-search-params.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
|
||||
export const teamJoinSearchParams = SearchParams.define({
|
||||
code: SP.param(z.string().nullable(), { loader: true }),
|
||||
});
|
||||
13
apps/web-react/app/features/team/team-urls.ts
Normal file
13
apps/web-react/app/features/team/team-urls.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { teamPage } from "~/utils/urls";
|
||||
import { teamJoinSearchParams } from "./team-search-params";
|
||||
|
||||
export const joinTeamPage = ({
|
||||
customUrl,
|
||||
inviteCode,
|
||||
}: {
|
||||
customUrl: string;
|
||||
inviteCode: string;
|
||||
}) =>
|
||||
teamJoinSearchParams.href(`${teamPage(customUrl)}/join`, {
|
||||
code: inviteCode,
|
||||
});
|
||||
76
apps/web-react/app/features/team/team-utils.test.ts
Normal file
76
apps/web-react/app/features/team/team-utils.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { subsOfResult } from "./team-utils";
|
||||
|
||||
describe("subsOfResult()", () => {
|
||||
test("returns empty array if all participants are current members", () => {
|
||||
const result = {
|
||||
participants: [{ id: 1 }, { id: 2 }],
|
||||
startsAt: 1000,
|
||||
};
|
||||
const members = [
|
||||
{ userId: 1, createdAt: 500, leftAt: null },
|
||||
{ userId: 2, createdAt: 600, leftAt: null },
|
||||
];
|
||||
const subs = subsOfResult(result, members);
|
||||
expect(subs).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns participant not in members as sub", () => {
|
||||
const result = {
|
||||
participants: [{ id: 1 }, { id: 2 }],
|
||||
startsAt: 1000,
|
||||
};
|
||||
const members = [{ userId: 1, createdAt: 500, leftAt: null }];
|
||||
const subs = subsOfResult(result, members);
|
||||
expect(subs).toEqual([{ id: 2 }]);
|
||||
});
|
||||
|
||||
test("returns participant as sub if they left before result startTime", () => {
|
||||
const result = {
|
||||
participants: [{ id: 1 }, { id: 2 }],
|
||||
startsAt: 1000,
|
||||
};
|
||||
const members = [
|
||||
{ userId: 1, createdAt: 500, leftAt: 900 },
|
||||
{ userId: 2, createdAt: 600, leftAt: null },
|
||||
];
|
||||
const subs = subsOfResult(result, members);
|
||||
expect(subs).toEqual([{ id: 1 }]);
|
||||
});
|
||||
|
||||
test("does not return participant as sub if they were a member during result", () => {
|
||||
const result = {
|
||||
participants: [{ id: 1 }, { id: 2 }],
|
||||
startsAt: 1000,
|
||||
};
|
||||
const members = [
|
||||
{ userId: 1, createdAt: 500, leftAt: 2000 },
|
||||
{ userId: 2, createdAt: 600, leftAt: null },
|
||||
];
|
||||
const subs = subsOfResult(result, members);
|
||||
expect(subs).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns multiple subs correctly", () => {
|
||||
const result = {
|
||||
participants: [{ id: 1 }, { id: 2 }, { id: 3 }],
|
||||
startsAt: 1000,
|
||||
};
|
||||
const members = [
|
||||
{ userId: 1, createdAt: 500, leftAt: 900 },
|
||||
{ userId: 2, createdAt: 600, leftAt: null },
|
||||
];
|
||||
const subs = subsOfResult(result, members);
|
||||
expect(subs).toEqual([{ id: 1 }, { id: 3 }]);
|
||||
});
|
||||
|
||||
test("returns empty array if no participants", () => {
|
||||
const result = {
|
||||
participants: [],
|
||||
startsAt: 1000,
|
||||
};
|
||||
const members = [{ userId: 1, createdAt: 500, leftAt: null }];
|
||||
const subs = subsOfResult(result, members);
|
||||
expect(subs).toEqual([]);
|
||||
});
|
||||
});
|
||||
116
apps/web-react/app/features/team/team-utils.ts
Normal file
116
apps/web-react/app/features/team/team-utils.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { Tables } from "~/db/tables";
|
||||
import type {
|
||||
MemberRole,
|
||||
MemberRoleType,
|
||||
} from "~/features/team/team-constants";
|
||||
import type * as TeamRepository from "./TeamRepository.server";
|
||||
import { NON_PLAYER_TEAM_ROLES, TEAM } from "./team-constants";
|
||||
|
||||
export function isTeamOwner({
|
||||
team,
|
||||
user,
|
||||
}: {
|
||||
team: TeamRepository.findByCustomUrl;
|
||||
user?: { id: number };
|
||||
}) {
|
||||
if (!user) return false;
|
||||
|
||||
return team.members.some((member) => member.isOwner && member.id === user.id);
|
||||
}
|
||||
|
||||
export function isTeamMember({
|
||||
team,
|
||||
user,
|
||||
}: {
|
||||
team: TeamRepository.findByCustomUrl;
|
||||
user?: { id: number };
|
||||
}) {
|
||||
if (!user) return false;
|
||||
|
||||
return team.members.some((member) => member.id === user.id);
|
||||
}
|
||||
|
||||
export function isTeamFull(team: TeamRepository.findByCustomUrl) {
|
||||
return team.members.length >= TEAM.MAX_MEMBER_COUNT;
|
||||
}
|
||||
|
||||
export function canAddCustomizedColors(team: {
|
||||
members: { patronTier: number | null }[];
|
||||
}) {
|
||||
return team.members.some(
|
||||
(member) => member.patronTier && member.patronTier >= 2,
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the user who will become the new owner after old one leaves */
|
||||
export function resolveNewOwner(
|
||||
members: Array<{
|
||||
id: number;
|
||||
username: string;
|
||||
isOwner: number;
|
||||
isManager: number;
|
||||
}>,
|
||||
) {
|
||||
const managers = members.filter((m) => m.isManager && !m.isOwner);
|
||||
if (managers.length > 0) {
|
||||
return managers.sort((a, b) => a.id - b.id)[0];
|
||||
}
|
||||
|
||||
const regularMembers = members.filter((m) => !m.isOwner);
|
||||
if (regularMembers.length > 0) {
|
||||
return regularMembers.sort((a, b) => a.id - b.id)[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves how a team member's role should be classified. For custom roles the explicit
|
||||
* `roleType` is authoritative; for predefined roles it is derived from {@link NON_PLAYER_TEAM_ROLES}.
|
||||
* Returns `null` when the member has no role at all (treated as a player by callers).
|
||||
*/
|
||||
export function getMemberRoleType(member: {
|
||||
role: MemberRole | null;
|
||||
roleType: MemberRoleType | null;
|
||||
}): MemberRoleType | null {
|
||||
if (member.roleType) return member.roleType;
|
||||
if (!member.role) return null;
|
||||
return NON_PLAYER_TEAM_ROLES.includes(member.role) ? "OTHER" : "PLAYER";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of participant IDs who are considered "substitutes" for a given tournament result,
|
||||
* based on the team's member history and the result's participants.
|
||||
*
|
||||
* A participant is considered a substitute if both:
|
||||
* - They are not a current member (i.e., their `leftAt` is set).
|
||||
* - They are not a past member who was part of the team during the result's start time.
|
||||
*/
|
||||
export function subsOfResult<T extends { id: number }>(
|
||||
result: { participants: Array<T>; startsAt: number },
|
||||
members: Array<Pick<Tables["TeamMember"], "userId" | "createdAt" | "leftAt">>,
|
||||
) {
|
||||
const currentMembers = members.filter((member) => !member.leftAt);
|
||||
const pastMembers = members.filter((member) => member.leftAt);
|
||||
|
||||
const subs = result.participants.reduce((acc: Array<T>, cur) => {
|
||||
if (currentMembers.some((member) => member.userId === cur.id)) return acc;
|
||||
if (
|
||||
pastMembers.some(
|
||||
(member) =>
|
||||
member.userId === cur.id &&
|
||||
member.createdAt < result.startsAt &&
|
||||
member.leftAt &&
|
||||
member.leftAt > result.startsAt,
|
||||
)
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.push(cur);
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return subs;
|
||||
}
|
||||
293
apps/web-react/app/features/team/team.module.css
Normal file
293
apps/web-react/app/features/team/team.module.css
Normal file
@@ -0,0 +1,293 @@
|
||||
.banner {
|
||||
background-image:
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0),
|
||||
rgba(255, 255, 255, 0),
|
||||
rgba(0, 0, 0, 0.6)
|
||||
),
|
||||
var(--team-banner-img);
|
||||
background-size: cover;
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 1;
|
||||
display: grid;
|
||||
grid-template-areas: "flags ." "avatar name";
|
||||
padding: var(--s-5);
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
|
||||
.bannerPlaceholder {
|
||||
height: 6rem;
|
||||
background-color: var(--color-accent-low);
|
||||
}
|
||||
|
||||
.bannerFlags {
|
||||
grid-area: flags;
|
||||
margin-top: -5px;
|
||||
display: none;
|
||||
column-gap: var(--s-4);
|
||||
}
|
||||
|
||||
.bannerName {
|
||||
grid-area: name;
|
||||
align-self: flex-end;
|
||||
justify-self: flex-end;
|
||||
font-size: 36px;
|
||||
line-height: 1;
|
||||
font-weight: var(--weight-bold);
|
||||
color: #fff;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bskyLink {
|
||||
padding: var(--s-1);
|
||||
border: 1px solid;
|
||||
border-radius: 50%;
|
||||
border-color: #1285fe;
|
||||
background-color: #1285fe2f;
|
||||
height: 24.4px;
|
||||
width: 24.4px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
& > svg {
|
||||
width: 0.9rem;
|
||||
}
|
||||
|
||||
& path {
|
||||
fill: #1285fe;
|
||||
}
|
||||
}
|
||||
|
||||
.bannerAvatar {
|
||||
grid-area: avatar;
|
||||
align-self: flex-end;
|
||||
margin-bottom: -110px;
|
||||
|
||||
& > div {
|
||||
padding: var(--s-2);
|
||||
background-color: var(--color-bg);
|
||||
border-radius: var(--radius-avatar);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 7rem;
|
||||
}
|
||||
|
||||
& img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-avatar);
|
||||
}
|
||||
}
|
||||
|
||||
.bannerAvatarSpacer {
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
.mobileNameCountry {
|
||||
display: flex;
|
||||
font-size: var(--font-xl);
|
||||
align-items: center;
|
||||
font-weight: var(--weight-bold);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mobileTeamName {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.bannerTag {
|
||||
font-size: var(--font-sm);
|
||||
background-color: var(--color-accent-low);
|
||||
color: var(--color-accent);
|
||||
padding: var(--s-1) var(--s-1-5);
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
|
||||
.bannerTagDesktop {
|
||||
position: absolute;
|
||||
bottom: 41px;
|
||||
right: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bannerTagMobile {
|
||||
font-size: var(--font-xs);
|
||||
padding: var(--s-0-5) var(--s-1);
|
||||
margin-block: var(--s-1);
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.results {
|
||||
background-color: var(--color-bg-higher);
|
||||
max-width: 32rem;
|
||||
margin: 0 auto;
|
||||
border-radius: var(--radius-box);
|
||||
padding: var(--s-1) var(--s-4);
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: var(--font-sm);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: min(100%, 48rem);
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resultsPlacements {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
gap: var(--s-4);
|
||||
|
||||
& > li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
}
|
||||
|
||||
.member {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.memberSection {
|
||||
background-color: var(--color-bg-high);
|
||||
border-radius: var(--radius-box);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
font-size: var(--font-xl);
|
||||
font-weight: var(--weight-bold);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 4.5rem;
|
||||
}
|
||||
|
||||
.memberAvatarNameContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-4);
|
||||
color: var(--color-text);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.memberAvatar {
|
||||
background-color: var(--color-bg);
|
||||
padding: var(--s-2);
|
||||
border-radius: var(--radius-avatar);
|
||||
}
|
||||
|
||||
.memberRole {
|
||||
margin-left: auto;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-text-high);
|
||||
margin-inline-end: var(--s-3);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.memberRoleMobile {
|
||||
color: var(--color-text-high);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.memberCardContainer {
|
||||
width: 16rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.memberCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-3);
|
||||
background-color: var(--color-bg-high);
|
||||
border-radius: var(--radius-box);
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.memberCardName {
|
||||
font-weight: var(--weight-bold);
|
||||
margin-block-start: var(--s-0-5);
|
||||
}
|
||||
|
||||
.inviteContainer {
|
||||
margin-block-start: var(--s-16);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-12);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.imageLinksList {
|
||||
display: flex;
|
||||
gap: var(--s-8);
|
||||
padding-left: var(--s-4);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.formDivider {
|
||||
margin-block: var(--s-6);
|
||||
}
|
||||
|
||||
.mapPreferencesDivider {
|
||||
margin-block-start: var(--s-6);
|
||||
margin-block-end: var(--s-2);
|
||||
}
|
||||
|
||||
@container (width >= 640px) {
|
||||
.bannerFlags {
|
||||
display: flex;
|
||||
}
|
||||
.bannerName {
|
||||
display: flex;
|
||||
}
|
||||
.bannerTagDesktop {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
.bannerAvatar > div {
|
||||
width: 10rem;
|
||||
}
|
||||
|
||||
.bannerAvatar {
|
||||
margin-left: var(--s-2);
|
||||
margin-bottom: -90px;
|
||||
}
|
||||
|
||||
.mobileNameCountry {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.member {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.memberCardContainer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bannerPlaceholder {
|
||||
height: 12rem;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
20
apps/web-react/app/features/team/tests/fixtures.ts
Normal file
20
apps/web-react/app/features/team/tests/fixtures.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { REGULAR_USER_TEST_ID } from "~/db/seed/constants";
|
||||
import * as TeamFactory from "~/db/seed/factories/TeamFactory";
|
||||
import { ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
|
||||
/** A team the regular user owns, so acting as "regular" exercises the owner paths. */
|
||||
export const createTeamOwnedByRegular = (name: string, isMainTeam = true) =>
|
||||
TeamFactory.create({
|
||||
name,
|
||||
isMainTeam,
|
||||
memberUserIds: [REGULAR_USER_TEST_ID],
|
||||
});
|
||||
|
||||
/** A team the regular user is a member but not the owner of. */
|
||||
export const createTeamWithRegularMember = (
|
||||
overrides: Partial<Parameters<typeof TeamFactory.create>[0]> = {},
|
||||
) =>
|
||||
TeamFactory.create({
|
||||
memberUserIds: [ADMIN_ID, REGULAR_USER_TEST_ID],
|
||||
...overrides,
|
||||
});
|
||||
Reference in New Issue
Block a user