feat(team-calc) : add character-wide team import

This commit is contained in:
2026-10-06 02:53:10 +07:00 Unverified
parent c856904c12
commit c851693854
6 changed files with 447 additions and 92 deletions
+18
View File
@@ -37,6 +37,7 @@ import {
} from "@/lib/catalog/sync";
import {
getAdminTeamImportGuide,
listAdminTeamImportsForCharacter,
listAdminTeamImportGuides,
} from "@/lib/guides/queries";
@@ -130,6 +131,23 @@ export async function loadTeamImportGuide(guideId: string) {
}
}
export async function listTeamImportsForCharacter(currentGuideId: string, characterKey: string) {
await requireAdmin();
const guideId = z.string().uuid().safeParse(currentGuideId);
const character = z.string().trim().min(1).max(64).safeParse(characterKey);
if (!guideId.success || !character.success) {
return { status: "error" as const, message: "Guide หรือตัวละครไม่ถูกต้อง" };
}
try {
return {
status: "success" as const,
teams: await listAdminTeamImportsForCharacter(guideId.data, character.data),
};
} catch {
return { status: "error" as const, message: "โหลด Team จาก Guide อื่นไม่สำเร็จ" };
}
}
export async function createGuideExtra(guideId: string, input: unknown) {
const admin = await requireAdmin();
try {
+298 -70
View File
@@ -49,6 +49,7 @@ import {
saveConstellationSection,
saveGuideExtra,
listTeamImportGuides,
listTeamImportsForCharacter,
loadTeamImportGuide,
saveTeamSection,
saveWeaponSection,
@@ -131,6 +132,7 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Toggle } from "@/components/ui/toggle";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -172,6 +174,7 @@ import {
applyPresetFields,
applyTeamFields,
cloneTeamImports,
filterTeamImportsByName,
findInvalidTeamCatalogReference,
findUnsupportedTeamWeapon,
limitPresets,
@@ -2628,6 +2631,103 @@ type TeamImportGuide = {
}>;
};
type TeamEverywhereImport = {
id: string;
title: string;
rotationSeconds: string;
combo: string;
guideName: string;
conditionName: string | null;
members: Array<TeamToolValue["members"][number] & { characterName: string }>;
};
function TeamImportPreviewCard({
team,
characters,
weapons,
artifacts,
checked,
checkboxId,
onCheckedChange,
sourceLabel,
disabled = false,
}: {
team: TeamImportGuide["groups"][number]["teams"][number];
characters: CatalogItem[];
weapons: CatalogItem[];
artifacts: CatalogItem[];
checked: boolean;
checkboxId: string;
onCheckedChange: (checked: boolean) => void;
sourceLabel?: string;
disabled?: boolean;
}) {
const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0);
const seconds = Number(team.rotationSeconds);
return (
<Card className={cn(checked && "ring-2 ring-primary")}>
<CardHeader>
<CardTitle>{team.title}</CardTitle>
<CardDescription>โรเทชั่น {seconds} วิ{sourceLabel ? ` · ${sourceLabel}` : ""}</CardDescription>
<CardAction>
<Field orientation="horizontal">
<Checkbox id={checkboxId} checked={checked} disabled={disabled} onCheckedChange={(value) => onCheckedChange(value === true)} />
<FieldLabel htmlFor={checkboxId}>{disabled ? "ชื่อซ้ำ" : "เลือก"}</FieldLabel>
</Field>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-4 gap-2">
{team.members.map((member, index) => {
const character = characters.find((item) => item.key === member.characterKey);
const weapon = weapons.find((item) => item.key === member.weaponKey);
const artifact = artifacts.find((item) => item.key === member.artifactKey);
const secondArtifact = artifacts.find((item) => item.key === member.secondArtifactKey);
return (
<div key={`${member.characterKey}-${index}`} className="min-w-0 text-center">
<div className="flex items-center justify-center gap-2">
<div className={cn("relative size-16 shrink-0 overflow-hidden rounded-lg bg-muted", characterRarityGradientClass(member.characterKey, isCustomTravelerKey(member.characterKey) ? 5 : character?.rarity))}>
{character ? <Image src={character.imageUrl} alt={character.name} fill sizes="64px" className="object-contain" /> : null}
</div>
<div className="flex flex-col gap-1">
{weapon ? (
<div className={cn("relative size-7 overflow-hidden rounded-md", rarityGradientClass(weapon.rarity))}>
<Image src={weapon.imageUrl} alt={weapon.name} fill sizes="28px" className="object-contain p-0.5" />
</div>
) : null}
{artifact || secondArtifact ? (
<TeamArtifactIcon
first={artifact ? { ...artifact, rarity: artifact.rarity ?? null } : null}
second={secondArtifact ? { ...secondArtifact, rarity: secondArtifact.rarity ?? null } : null}
sizes="28px"
className="size-7 shrink-0 scale-100"
/>
) : null}
</div>
</div>
<div className="mt-2 flex items-center justify-center gap-1">
<Badge variant="secondary">{teamConstellationLabel(member.constellation)}</Badge>
<Badge variant="secondary">{teamRefinementLabel(member.refinement)}</Badge>
</div>
<p className="mt-1 truncate text-sm font-medium">{character?.name ?? member.characterName}</p>
<p className="mt-1 text-sm font-semibold">{Number(member.damage).toLocaleString()}</p>
<p className="text-xs text-muted-foreground">{total ? ((Number(member.damage) / total) * 100).toFixed(1) : "0.0"}%</p>
</div>
);
})}
</div>
<Separator />
<div className="flex items-end justify-between gap-4">
<div><p className="text-sm text-muted-foreground">ดาเมจรวม</p><p className="font-semibold">{total.toLocaleString()}</p></div>
<div className="text-right"><p className="text-sm text-muted-foreground">DPS</p><p className="text-3xl font-semibold text-primary">{seconds > 0 ? (total / seconds).toLocaleString(undefined, { maximumFractionDigits: 0 }) : "0"}</p></div>
</div>
<p className="rounded-lg bg-muted p-3 text-sm leading-6">{team.combo}</p>
</CardContent>
</Card>
);
}
function TeamEditor({
guide,
characters,
@@ -2674,6 +2774,15 @@ function TeamEditor({
const [importOpen, setImportOpen] = useState(false);
const [importFiles, setImportFiles] = useState<File[]>([]);
const [guideImportOpen, setGuideImportOpen] = useState(false);
const [everywhereImportOpen, setEverywhereImportOpen] = useState(false);
const everywhereSearchId = useRef(0);
const [everywhereCharacterKey, setEverywhereCharacterKey] = useState("");
const [everywhereSearchedKey, setEverywhereSearchedKey] = useState("");
const [everywhereTeams, setEverywhereTeams] = useState<TeamEverywhereImport[]>([]);
const [everywhereLoading, setEverywhereLoading] = useState(false);
const [everywhereError, setEverywhereError] = useState<string | null>(null);
const [everywhereSelectedIds, setEverywhereSelectedIds] = useState<Set<string>>(() => new Set());
const [ignoreSameName, setIgnoreSameName] = useState(true);
const [importGuides, setImportGuides] = useState<Array<{ id: string; name: string; characterKey: string; coverUrl: string }> | null>(null);
const [activeImportGuide, setActiveImportGuide] = useState<TeamImportGuide | null>(null);
const [selectedImports, setSelectedImports] = useState<Record<string, Omit<TeamToolValue, "clientId">>>({});
@@ -2737,6 +2846,12 @@ function TeamEditor({
const filteredTeams = teamSearch.items;
const filteredImportTeams = importTeamSearch.items;
const filteredImportGuides = importGuideSearch.items;
const eligibleEverywhereTeams = filterTeamImportsByName(teams, everywhereTeams, ignoreSameName);
const eligibleEverywhereIds = new Set(eligibleEverywhereTeams.map((team) => team.id));
const selectedEverywhereTeams = eligibleEverywhereTeams.filter((team) => everywhereSelectedIds.has(team.id));
const allEverywhereTeamsSelected = eligibleEverywhereTeams.length > 0
&& eligibleEverywhereTeams.every((team) => everywhereSelectedIds.has(team.id));
const availableTeamSlots = 30 - teams.length;
const value = {
groups: teamGroups.map((group) => ({ name: group.name.trim() || null, note: group.note.trim() || null, iconMediaId: group.iconMediaId, teams: group.teams.map((team) => ({
title: team.title,
@@ -3055,6 +3170,54 @@ function TeamEditor({
if (result.status === "success") setImportGuides(result.guides);
else setGuideImportError(result.message);
};
const openEverywhereImport = () => {
everywhereSearchId.current += 1;
setImportOpen(false);
setEverywhereImportOpen(true);
setEverywhereCharacterKey("");
setEverywhereSearchedKey("");
setEverywhereTeams([]);
setEverywhereSelectedIds(new Set());
setIgnoreSameName(true);
setEverywhereError(null);
setEverywhereLoading(false);
};
const searchEverywhereTeams = async (characterKey: string) => {
const searchId = ++everywhereSearchId.current;
setEverywhereSearchedKey("");
setEverywhereTeams([]);
setEverywhereSelectedIds(new Set());
setEverywhereError(null);
setEverywhereLoading(true);
const result = await listTeamImportsForCharacter(guide.id, characterKey);
if (searchId !== everywhereSearchId.current) return;
setEverywhereLoading(false);
if (result.status === "success") {
setEverywhereTeams(result.teams);
setEverywhereSearchedKey(characterKey);
} else setEverywhereError(result.message);
};
const importEverywhereTeams = (sourceTeams: TeamEverywhereImport[]) => {
if (!sourceTeams.length) return;
const importedTeams = sourceTeams.map((team) => ({
title: team.title,
rotationSeconds: team.rotationSeconds,
combo: team.combo,
members: team.members.map((member) => ({
characterKey: member.characterKey,
weaponKey: member.weaponKey,
artifactKey: member.artifactKey,
secondArtifactKey: member.secondArtifactKey,
constellation: member.constellation,
refinement: member.refinement,
damage: member.damage,
})),
}));
if (!appendImportedTeams(importedTeams)) return;
setEverywhereImportOpen(false);
setEverywhereSelectedIds(new Set());
toast.success(`Import ${importedTeams.length} Team แล้ว`);
};
const enterImportGuide = async (guideId: string) => {
setGuideImportError(null);
setGuideImportLoading("teams");
@@ -3772,6 +3935,18 @@ function TeamEditor({
</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-col gap-4 overflow-y-auto pr-1">
<Card size="sm">
<CardHeader>
<CardTitle>Import Everywhere</CardTitle>
<CardDescription>เลือกตัวละคร แล้วค้นหา Team จาก Guide อื่นที่มีตัวละครนั้นอยู่ในทีม</CardDescription>
</CardHeader>
<CardFooter>
<Button type="button" variant="outline" onClick={openEverywhereImport}>
<SearchIcon data-icon="inline-start" />
เลือกตัวละคร
</Button>
</CardFooter>
</Card>
<Card size="sm">
<CardHeader>
<CardTitle>เลือกจาก Guide</CardTitle>
@@ -3815,6 +3990,117 @@ function TeamEditor({
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={everywhereImportOpen} onOpenChange={(open) => {
setEverywhereImportOpen(open);
if (!open) {
everywhereSearchId.current += 1;
setEverywhereLoading(false);
}
}}>
<DialogContent className="max-h-[90svh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Import Everywhere</DialogTitle>
<DialogDescription>
เลือกตัวละครเพื่อค้นหา Team จาก Guide อื่น แล้วดูตัวอย่างก่อนนำเข้า Condition ปัจจุบัน
</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-col gap-4 overflow-y-auto pr-1">
<Field>
<FieldLabel>ตัวละครใน Team</FieldLabel>
<CatalogDropdown
items={characters}
value={everywhereCharacterKey}
onValueChange={(key) => {
setEverywhereCharacterKey(key);
if (key) void searchEverywhereTeams(key);
else {
everywhereSearchId.current += 1;
setEverywhereSearchedKey("");
setEverywhereTeams([]);
setEverywhereSelectedIds(new Set());
setEverywhereError(null);
setEverywhereLoading(false);
}
}}
placeholder="เลือกตัวละคร"
label="เลือกตัวละครที่อยู่ใน Team"
showSelectedImage
rarityBackground
characterBackground
/>
</Field>
<Field orientation="horizontal">
<Switch id="ignore-same-team-name" checked={ignoreSameName} onCheckedChange={setIgnoreSameName} />
<FieldLabel htmlFor="ignore-same-team-name">Ignore same name</FieldLabel>
</Field>
{everywhereError ? (
<Alert variant="destructive"><AlertTitle>โหลดข้อมูลไม่สำเร็จ</AlertTitle><AlertDescription>{everywhereError}</AlertDescription></Alert>
) : null}
{everywhereLoading ? (
<div aria-busy="true" aria-label="กำลังโหลด Team" className="grid gap-4 lg:grid-cols-2">
{Array.from({ length: 4 }, (_, index) => <Skeleton key={index} className="h-72 rounded-xl" />)}
</div>
) : everywhereSearchedKey && everywhereTeams.length ? (
<>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">
พบ {everywhereTeams.length} Team · เลือกได้ {eligibleEverywhereTeams.length} Team · Condition นี้เหลือที่ว่าง {availableTeamSlots} Team
</p>
<Button
type="button"
size="sm"
variant="outline"
disabled={!eligibleEverywhereTeams.length}
onClick={() => setEverywhereSelectedIds(
allEverywhereTeamsSelected ? new Set() : new Set(eligibleEverywhereIds),
)}
>
{allEverywhereTeamsSelected ? "ยกเลิกเลือกทั้งหมด" : "เลือกทั้งหมด"}
</Button>
</div>
{eligibleEverywhereTeams.length > availableTeamSlots ? (
<p className="text-sm text-muted-foreground">Import All ใช้ไม่ได้เมื่อจำนวน Team เกินที่ว่าง กรุณาเลือก Team ไม่เกิน {availableTeamSlots} Team</p>
) : null}
<div className="grid gap-4 lg:grid-cols-2">
{everywhereTeams.map((team) => {
const eligible = eligibleEverywhereIds.has(team.id);
return (
<TeamImportPreviewCard
key={team.id}
team={team}
characters={characters}
weapons={weapons}
artifacts={artifacts}
checked={eligible && everywhereSelectedIds.has(team.id)}
checkboxId={`everywhere-team-${team.id}`}
sourceLabel={`${team.guideName}${team.conditionName ? ` · ${team.conditionName}` : ""}`}
disabled={!eligible}
onCheckedChange={(checked) => setEverywhereSelectedIds((current) => {
const next = new Set(current);
if (checked) next.add(team.id);
else next.delete(team.id);
return next;
})}
/>
);
})}
</div>
</>
) : everywhereSearchedKey && !everywhereError ? (
<Empty><EmptyHeader><EmptyTitle>ไม่พบ Team</EmptyTitle><EmptyDescription>ยังไม่มี Team จาก Guide อื่นที่มีตัวละครนี้</EmptyDescription></EmptyHeader></Empty>
) : null}
</div>
<DialogFooter className="flex-wrap">
<Button type="button" variant="outline" onClick={() => setEverywhereImportOpen(false)}>ปิด</Button>
<Button type="button" variant="outline" disabled={!eligibleEverywhereTeams.length || eligibleEverywhereTeams.length > availableTeamSlots} onClick={() => importEverywhereTeams(eligibleEverywhereTeams)}>
Import All {eligibleEverywhereTeams.length} Team
</Button>
<Button type="button" disabled={!selectedEverywhereTeams.length || selectedEverywhereTeams.length > availableTeamSlots} onClick={() => importEverywhereTeams(selectedEverywhereTeams)}>
Import Selected {selectedEverywhereTeams.length} Team
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={guideImportOpen} onOpenChange={setGuideImportOpen}>
<DialogContent className="max-h-[90svh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden sm:max-w-5xl">
<DialogHeader>
@@ -3956,22 +4242,17 @@ function TeamEditor({
{filteredImportTeams.length ? (
<div className="grid gap-4 lg:grid-cols-2">
{filteredImportTeams.map((team) => {
const checked = Boolean(selectedImports[team.id]);
const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0);
const seconds = Number(team.rotationSeconds);
return (
<Card key={team.id} className={cn(checked && "ring-2 ring-primary")}>
<CardHeader>
<CardTitle>{team.title}</CardTitle>
<CardDescription>โรเทชั่น {seconds} วิ</CardDescription>
<CardAction>
<Field orientation="horizontal">
<Checkbox
id={`guide-team-${team.id}`}
checked={checked}
onCheckedChange={(nextChecked) => setSelectedImports((current) => {
if (nextChecked === true) {
{filteredImportTeams.map((team) => (
<TeamImportPreviewCard
key={team.id}
team={team}
characters={characters}
weapons={weapons}
artifacts={artifacts}
checked={Boolean(selectedImports[team.id])}
checkboxId={`guide-team-${team.id}`}
onCheckedChange={(checked) => setSelectedImports((current) => {
if (checked) {
return { ...current, [team.id]: {
title: team.title,
rotationSeconds: team.rotationSeconds,
@@ -3992,60 +4273,7 @@ function TeamEditor({
return next;
})}
/>
<FieldLabel htmlFor={`guide-team-${team.id}`}>เลือก</FieldLabel>
</Field>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-4 gap-2">
{team.members.map((member) => {
const character = characters.find((item) => item.key === member.characterKey);
const weapon = weapons.find((item) => item.key === member.weaponKey);
const artifact = artifacts.find((item) => item.key === member.artifactKey);
const secondArtifact = artifacts.find((item) => item.key === member.secondArtifactKey);
return (
<div key={member.characterKey} className="min-w-0 text-center">
<div className="flex items-center justify-center gap-2">
<div className={cn("relative size-16 shrink-0 overflow-hidden rounded-lg bg-muted", characterRarityGradientClass(member.characterKey, isCustomTravelerKey(member.characterKey) ? 5 : character?.rarity))}>
{character ? <Image src={character.imageUrl} alt={character.name} fill sizes="64px" className="object-contain" /> : null}
</div>
<div className="flex flex-col gap-1">
{weapon ? (
<div className={cn("relative size-7 overflow-hidden rounded-md", rarityGradientClass(weapon.rarity))}>
<Image src={weapon.imageUrl} alt={weapon.name} fill sizes="28px" className="object-contain p-0.5" />
</div>
) : null}
{artifact || secondArtifact ? (
<TeamArtifactIcon
first={artifact ? { ...artifact, rarity: artifact.rarity ?? null } : null}
second={secondArtifact ? { ...secondArtifact, rarity: secondArtifact.rarity ?? null } : null}
sizes="28px"
className="size-7 shrink-0 scale-100"
/>
) : null}
</div>
</div>
<div className="mt-2 flex items-center justify-center gap-1">
<Badge variant="secondary">{teamConstellationLabel(member.constellation)}</Badge>
<Badge variant="secondary">{teamRefinementLabel(member.refinement)}</Badge>
</div>
<p className="mt-1 truncate text-sm font-medium">{character?.name ?? member.characterName}</p>
<p className="mt-1 text-sm font-semibold">{Number(member.damage).toLocaleString()}</p>
<p className="text-xs text-muted-foreground">{total ? ((Number(member.damage) / total) * 100).toFixed(1) : "0.0"}%</p>
</div>
);
})}
</div>
<Separator />
<div className="flex items-end justify-between gap-4">
<div><p className="text-sm text-muted-foreground">ดาเมจรวม</p><p className="font-semibold">{total.toLocaleString()}</p></div>
<div className="text-right"><p className="text-sm text-muted-foreground">DPS</p><p className="text-3xl font-semibold text-primary">{seconds > 0 ? (total / seconds).toLocaleString(undefined, { maximumFractionDigits: 0 }) : "0"}</p></div>
</div>
<p className="rounded-lg bg-muted p-3 text-sm leading-6">{team.combo}</p>
</CardContent>
</Card>
);
})}
))}
</div>
) : (
<Empty>
+31 -2
View File
@@ -26,10 +26,10 @@ vi.mock("@/db", () => ({
import {
artifactConditions, artifactProfiles, constellationConditions, extraSectionRows,
guides, guideSections, teamConditions, weaponConditions,
guides, guideSections, teamCompositions, teamConditions, teamMembers, weaponConditions,
catalogMaterials, guidePosterBleeding, media,
} from "@/db/schema";
import { getAdminGuideContent } from "./queries";
import { getAdminGuideContent, listAdminTeamImportsForCharacter } from "./queries";
beforeEach(() => {
mocks.reads.length = 0;
@@ -102,3 +102,32 @@ describe("admin section reads", () => {
expect(mocks.reads.filter((table) => table === media)).toHaveLength(1);
});
});
describe("team import lookup", () => {
it("hydrates matching teams and source labels with one member read", async () => {
mocks.rows.set(teamCompositions, [{
id: "team-1", title: "Example", rotationSeconds: "20", combo: "E > Q",
guideName: "Other Guide", conditionName: "Boss",
}]);
mocks.rows.set(teamMembers, [{
member: {
teamId: "team-1", characterKey: "character-1", weaponKey: "weapon-1",
artifactKey: "artifact-1", secondArtifactKey: null,
constellation: "2", refinement: "1", damage: "100",
},
characterName: "Character One",
}]);
const result = await listAdminTeamImportsForCharacter("current-guide", "character-1");
expect(result).toEqual([{
id: "team-1", title: "Example", rotationSeconds: "20", combo: "E > Q",
guideName: "Other Guide", conditionName: "Boss",
members: [{
characterKey: "character-1", characterName: "Character One", weaponKey: "weapon-1",
artifactKey: "artifact-1", secondArtifactKey: null,
constellation: "2", refinement: "1", damage: "100",
}],
}]);
expect(mocks.reads).toEqual([teamCompositions, teamMembers]);
});
});
+58
View File
@@ -200,6 +200,64 @@ export async function listAdminTeamImportGuides(excludeGuideId: string) {
}));
}
export async function listAdminTeamImportsForCharacter(
excludeGuideId: string,
characterKey: string,
) {
const matches = await getDb()
.select({
id: teamCompositions.id,
title: teamCompositions.title,
rotationSeconds: teamCompositions.rotationSeconds,
combo: teamCompositions.combo,
guideName: guides.name,
conditionName: teamConditions.name,
})
.from(teamCompositions)
.innerJoin(guides, eq(guides.id, teamCompositions.guideId))
.innerJoin(teamMembers, eq(teamMembers.teamId, teamCompositions.id))
.leftJoin(teamConditions, eq(teamConditions.id, teamCompositions.conditionId))
.where(and(
eq(teamMembers.characterKey, characterKey),
ne(guides.id, excludeGuideId),
isNull(guides.trashedAt),
))
.orderBy(asc(guides.name), asc(guides.id), asc(teamConditions.sortOrder), asc(teamCompositions.sortOrder), asc(teamCompositions.id));
if (!matches.length) return [];
const members = await getDb()
.select({ member: teamMembers, characterName: catalogCharacters.name })
.from(teamMembers)
.leftJoin(catalogCharacters, eq(catalogCharacters.key, teamMembers.characterKey))
.where(inArray(teamMembers.teamId, matches.map((team) => team.id)))
.orderBy(asc(teamMembers.position));
const membersByTeam = new Map<string, Array<{
characterKey: string;
characterName: string;
weaponKey: string | null;
artifactKey: string | null;
secondArtifactKey: string | null;
constellation: string;
refinement: string;
damage: string;
}>>();
for (const { member, characterName } of members) {
const group = membersByTeam.get(member.teamId) ?? [];
group.push({
characterKey: member.characterKey,
characterName: characterName ?? member.characterKey,
weaponKey: member.weaponKey,
artifactKey: member.artifactKey,
secondArtifactKey: member.secondArtifactKey,
constellation: member.constellation,
refinement: member.refinement,
damage: member.damage,
});
membersByTeam.set(member.teamId, group);
}
return matches.map((team) => ({ ...team, members: membersByTeam.get(team.id) ?? [] }));
}
export async function getAdminTeamImportGuide(guideId: string) {
const [guide] = await getDb()
.select({ id: guides.id, name: guides.name })
+7
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
applyTeamFields,
cloneTeamImports,
filterTeamImportsByName,
findInvalidTeamCatalogReference,
findUnsupportedTeamWeapon,
limitPresets,
@@ -160,6 +161,12 @@ describe("team bulk tools", () => {
expect(imported[0].members[0]).not.toBe(source.members[0]);
});
it("ignores existing and repeated names unless duplicate filtering is disabled", () => {
const candidates = [team("one", " Alpha "), team("two", "Beta"), team("three", "BETA")];
expect(filterTeamImportsByName([team("current", "alpha")], candidates, true).map((item) => item.clientId)).toEqual(["two"]);
expect(filterTeamImportsByName([team("current", "alpha")], candidates, false)).toEqual(candidates);
});
it("rejects malformed and unsupported team transfers", () => {
expect(() => parseTeamTransfer("not-json")).toThrow();
expect(() => parseTeamTransfer(JSON.stringify({
+15
View File
@@ -125,6 +125,21 @@ export function cloneTeamImports(
}));
}
export function filterTeamImportsByName<T extends { title: string }>(
currentTeams: readonly { title: string }[],
candidates: readonly T[],
ignoreSameName: boolean,
): T[] {
if (!ignoreSameName) return [...candidates];
const names = new Set(currentTeams.map((team) => team.title.trim().toLocaleLowerCase()));
return candidates.filter((team) => {
const name = team.title.trim().toLocaleLowerCase();
if (names.has(name)) return false;
names.add(name);
return true;
});
}
export function findInvalidTeamCatalogReference(
team: Omit<TeamToolValue, "clientId">,
characters: readonly TeamToolCatalogItem[],