Team member ordering

This commit is contained in:
Kalle
2026-06-14 15:45:41 +03:00
parent 538c75b13a
commit 7d610e6465
24 changed files with 245 additions and 50 deletions

View File

@@ -2212,13 +2212,14 @@ const detailedTeam = (seedVariation?: SeedVariation | null) => () => {
sql
.prepare(
/*sql*/ `
insert into "AllTeamMember" ("teamId", "userId", "role", "isOwner", "leftAt")
insert into "AllTeamMember" ("teamId", "userId", "role", "isOwner", "leftAt", "order")
values (
1,
${userId},
${i === 0 ? "'CAPTAIN'" : "'FRONTLINE'"},
${i === 0 ? 1 : 0},
${i < 4 ? "null" : "1672587342"}
${i < 4 ? "null" : "1672587342"},
${i}
)
`,
)
@@ -2290,12 +2291,13 @@ function otherTeams() {
sql
.prepare(
/*sql*/ `
insert into "AllTeamMember" ("teamId", "userId", "role", "isOwner")
insert into "AllTeamMember" ("teamId", "userId", "role", "isOwner", "order")
values (
${i},
${userId},
${j === 0 ? "'CAPTAIN'" : "'FRONTLINE'"},
${j === 0 ? 1 : 0}
${j === 0 ? 1 : 0},
${j}
)
`,
)

View File

@@ -102,6 +102,8 @@ export interface TeamMember {
customRole: string | null;
/** If customRole is defined, this classifies how the role should be treated */
roleType: MemberRoleType | null;
/** User-defined ordering of members within a team (ascending) */
order: Generated<number>;
teamId: number;
userId: number;
isMainTeam: DBBoolean;

View File

@@ -34,7 +34,8 @@ export function findAllUndisbanded() {
.innerJoin("User", "User.id", "TeamMemberWithSecondary.userId")
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
.select(["User.id", "User.username", "PlusTier.tier as plusTier"])
.whereRef("TeamMemberWithSecondary.teamId", "=", "Team.id"),
.whereRef("TeamMemberWithSecondary.teamId", "=", "Team.id")
.orderBy("TeamMemberWithSecondary.order", "asc"),
).as("members"),
])
.execute();
@@ -79,7 +80,7 @@ export function searchByName({
]),
]),
)
.orderBy("TeamMemberWithSecondary.isOwner", "desc"),
.orderBy("TeamMemberWithSecondary.order", "asc"),
).as("members"),
])
.where("Team.name", "like", `%${query}%`)
@@ -186,7 +187,8 @@ export function findByCustomUrl(
"User.patronTier",
userProfileWeapons(innerEb).as("weapons"),
])
.whereRef("TeamMemberWithSecondary.teamId", "=", "Team.id"),
.whereRef("TeamMemberWithSecondary.teamId", "=", "Team.id")
.orderBy("TeamMemberWithSecondary.order", "asc"),
).as("members"),
])
.$if(includeInviteCode, (qb) => qb.select("Team.inviteCode"))
@@ -321,7 +323,8 @@ export async function teamsByMemberUserId(
.selectFrom("TeamMemberWithSecondary as m2")
.innerJoin("User", "User.id", "m2.userId")
.select((eb) => [...commonUserSelect(eb), "m2.role", "m2.roleType"])
.whereRef("TeamMemberWithSecondary.teamId", "=", "m2.teamId"),
.whereRef("TeamMemberWithSecondary.teamId", "=", "m2.teamId")
.orderBy("m2.order", "asc"),
).as("members"),
])
.where("userId", "=", userId)
@@ -532,13 +535,24 @@ export function joinTeam({
const isMainTeam = Number(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 })
.values({ userId, teamId, isMainTeam, order })
.onConflict((oc) =>
oc.columns(["userId", "teamId"]).doUpdateSet({
leftAt: null,
isMainTeam,
order,
}),
)
.execute();
@@ -575,6 +589,7 @@ export function updateRoster({
customRole: Tables["TeamMember"]["customRole"];
roleType: Tables["TeamMember"]["roleType"];
isManager: boolean;
order: number;
}>;
kickedUserIds: number[];
}) {
@@ -591,6 +606,7 @@ export function updateRoster({
customRole: member.customRole,
roleType: member.roleType,
isManager: member.isManager ? 1 : 0,
order: member.order,
})
.where("teamId", "=", teamId)
.where("userId", "=", member.userId)

View File

@@ -59,7 +59,7 @@ export const action: ActionFunction = async ({ request, params }) => {
await TeamRepository.updateRoster({
teamId: team.id,
members: data.members.map((member) => {
members: data.members.map((member, index) => {
const isCustom = member.role === CUSTOM_ROLE_VALUE;
const existing = existingMembersById.get(member.userId);
const isProtectedMember = Boolean(
@@ -78,6 +78,7 @@ export const action: ActionFunction = async ({ request, params }) => {
isManager: isProtectedMember
? Boolean(existing?.isManager)
: member.isManager,
order: index,
};
}),
kickedUserIds,

View File

@@ -147,7 +147,10 @@ function MemberActions() {
className="stack md-plus"
data-testid={`member-row-${index}`}
>
<div className="stack horizontal sm items-center text-sm font-bold mb-2">
<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>

View File

@@ -69,6 +69,7 @@ export const updateRosterSchema = z
members: array({
max: TEAM.MAX_MEMBER_COUNT,
addable: false,
sortable: true,
field: fieldset({
fields: z.object({
userId: idConstant(),

View File

@@ -1100,6 +1100,69 @@ describe("SendouForm", () => {
expect((inputs[0] as HTMLInputElement).value).toBe("Bob");
});
test("sortable array renders move buttons and reorders items", async () => {
const schema = z.object({
members: array({
label: "labels.members",
min: 0,
max: 10,
sortable: true,
field: fieldset({
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
});
const screen = await renderForm(schema, {
defaultValues: { members: [{ name: "Alice" }, { name: "Bob" }] },
});
const moveDownButtons = screen.container.querySelectorAll(
'button[aria-label="Move down"]',
);
expect(moveDownButtons.length).toBe(2);
// the first item can't move up and the last can't move down
const moveUpButtons = screen.container.querySelectorAll(
'button[aria-label="Move up"]',
);
expect((moveUpButtons[0] as HTMLButtonElement).disabled).toBe(true);
expect((moveDownButtons[1] as HTMLButtonElement).disabled).toBe(true);
// move the first item down past the second
await userEvent.click(moveDownButtons[0]);
const inputs = screen.container.querySelectorAll('input[type="text"]');
expect((inputs[0] as HTMLInputElement).value).toBe("Bob");
expect((inputs[1] as HTMLInputElement).value).toBe("Alice");
});
test("non-sortable array renders no move buttons", async () => {
const schema = z.object({
members: array({
label: "labels.members",
min: 0,
max: 10,
field: fieldset({
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
});
const screen = await renderForm(schema, {
defaultValues: { members: [{ name: "Alice" }, { name: "Bob" }] },
});
const moveButtons = screen.container.querySelectorAll(
'button[aria-label="Move down"], button[aria-label="Move up"]',
);
expect(moveButtons.length).toBe(0);
});
test("removing an added fieldset row returns to a single non-removable row", async () => {
// Mirrors the staff form: a select field gives the row a non-empty default
// (role), so a freshly added row isn't "blank" yet is still pristine.

View File

@@ -16,13 +16,6 @@
border-radius: var(--header-border-radius) var(--header-border-radius) 0 0;
}
.dragHandle {
width: var(--s-5);
height: var(--s-5);
cursor: grab;
color: var(--color-text-high);
}
.headerLabel {
flex: 1;
font-size: var(--font-xs);

View File

@@ -1,4 +1,4 @@
import { Plus, Trash } from "lucide-react";
import { ChevronDown, ChevronUp, Plus, Trash } from "lucide-react";
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { isDeepEqual, omit } from "remeda";
@@ -110,43 +110,60 @@ export function ArrayFormField({
return ((value[idx] as Record<string, unknown>)?._key as string) ?? idx;
};
// Sorting is only offered for object arrays; primitive arrays are rendered
// inline without the fieldset header that carries the reorder controls.
const isSortable = Boolean(sortable) && isObjectArray;
const handleMoveAt = (index: number, direction: 1 | -1) => {
const target = index + direction;
if (target < 0 || target >= count) return;
const next = [...value];
[next[index], next[target]] = [next[target], next[index]];
onChange(next);
};
return (
<div className="stack md w-full">
{translatedLabel ? (
<div className="text-xs font-semi-bold">{translatedLabel}</div>
) : null}
{Array.from({ length: visibleCount }).map((_, idx) =>
isObjectArray ? (
<ArrayItemFieldset
key={itemKey(idx)}
index={idx}
canRemove={canRemoveAt(idx)}
onRemove={() => handleRemoveAt(idx)}
sortable={sortable}
>
{renderItem(idx, `${name}[${idx}]`)}
</ArrayItemFieldset>
) : (
<div
key={itemKey(idx)}
className="stack horizontal sm items-start w-full"
>
<div className={styles.itemInput}>
{isObjectArray
? Array.from({ length: visibleCount }).map((_, idx) => (
<ArrayItemFieldset
key={itemKey(idx)}
index={idx}
canRemove={canRemoveAt(idx)}
onRemove={() => handleRemoveAt(idx)}
sortable={isSortable}
canMoveUp={idx > 0}
canMoveDown={idx < count - 1}
onMoveUp={() => handleMoveAt(idx, -1)}
onMoveDown={() => handleMoveAt(idx, 1)}
>
{renderItem(idx, `${name}[${idx}]`)}
</ArrayItemFieldset>
))
: Array.from({ length: visibleCount }).map((_, idx) => (
<div
key={itemKey(idx)}
className="stack horizontal sm items-start w-full"
>
<div className={styles.itemInput}>
{renderItem(idx, `${name}[${idx}]`)}
</div>
{canRemoveAt(idx) ? (
<SendouButton
icon={<Trash />}
aria-label="Remove item"
size="small"
variant="minimal-destructive"
onPress={() => handleRemoveAt(idx)}
className={styles.removeButton}
/>
) : null}
</div>
{canRemoveAt(idx) ? (
<SendouButton
icon={<Trash />}
aria-label="Remove item"
size="small"
variant="minimal-destructive"
onPress={() => handleRemoveAt(idx)}
className={styles.removeButton}
/>
) : null}
</div>
),
)}
))}
{translatedError ? (
<FormMessage type="error">{translatedError}</FormMessage>
) : null}
@@ -175,18 +192,47 @@ function ArrayItemFieldset({
canRemove,
onRemove,
sortable,
canMoveUp,
canMoveDown,
onMoveUp,
onMoveDown,
}: {
index: number;
children: React.ReactNode;
canRemove: boolean;
onRemove: () => void;
sortable?: boolean;
canMoveUp?: boolean;
canMoveDown?: boolean;
onMoveUp?: () => void;
onMoveDown?: () => void;
}) {
return (
<fieldset className={styles.card}>
<div className={styles.header}>
{sortable ? <span className={styles.dragHandle}>☰</span> : null}
<legend className={styles.headerLabel}>#{index + 1}</legend>
{sortable ? (
<>
<SendouButton
shape="circle"
icon={<ChevronDown />}
aria-label="Move down"
size="small"
variant="minimal"
onPress={onMoveDown}
isDisabled={!canMoveDown}
/>
<SendouButton
shape="circle"
icon={<ChevronUp />}
aria-label="Move up"
size="small"
variant="minimal"
onPress={onMoveUp}
isDisabled={!canMoveUp}
/>
</>
) : null}
<SendouButton
className={canRemove ? undefined : "invisible"}
shape="circle"

View File

@@ -130,6 +130,8 @@ export interface FormFieldArray<T extends string, S extends z.ZodType>
field: S;
/** When false, the "Add" button is hidden (the array can only be edited/shrunk, not grown). Defaults to true. */
addable?: boolean;
/** When true, items (object arrays only) can be reordered via drag-and-drop and the new order is reflected in the value. */
sortable?: boolean;
}
interface FormFieldTimeRange<T extends string> extends FormFieldBase<T> {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -113,6 +113,53 @@ test.describe("Team page", () => {
await expect(page.getByText("Strategist").first()).toBeVisible();
});
test("reorders members via move buttons", async ({ page }) => {
await seed(page);
await impersonate(page, ADMIN_ID);
await navigate({ page, url: teamPage("alliance-rogue") });
await page.getByTestId("manage-roster-button").click();
const firstName = await page
.getByTestId("member-row-username-0")
.innerText();
const secondName = await page
.getByTestId("member-row-username-1")
.innerText();
expect(firstName).not.toBe(secondName);
const firstRow = page.locator("fieldset:has([data-testid='member-row-0'])");
const lastRow = page.locator("fieldset:has([data-testid='member-row-3'])");
// the first member can't move up and the last can't move down
await expect(
firstRow.getByRole("button", { name: "Move up" }),
).toBeDisabled();
await expect(
lastRow.getByRole("button", { name: "Move down" }),
).toBeDisabled();
// move the first member down one slot
await firstRow.getByRole("button", { name: "Move down" }).click();
await expect(page.getByTestId("member-row-username-0")).toHaveText(
secondName,
);
await expect(page.getByTestId("member-row-username-1")).toHaveText(
firstName,
);
await submit(page);
await navigate({ page, url: teamPage("alliance-rogue") });
await page.getByTestId("manage-roster-button").click();
// the new order is persisted
await expect(page.getByTestId("member-row-username-0")).toHaveText(
secondName,
);
});
test("deletes team", async ({ page }) => {
await seed(page);
await impersonate(page, ADMIN_ID);

View File

@@ -6,5 +6,24 @@ export function up(db) {
db.prepare(
/* sql */ `alter table "AllTeamMember" add "roleType" text`,
).run();
db.prepare(
/* sql */ `alter table "AllTeamMember" add "order" integer not null default 0`,
).run();
db.prepare(
/* sql */ `update "AllTeamMember"
set "order" = sub.rn
from (
select
"userId",
"teamId",
row_number() over (
partition by "teamId"
order by "isOwner" desc, "createdAt" asc, "userId" asc
) - 1 as rn
from "AllTeamMember"
) as sub
where "AllTeamMember"."userId" = sub."userId"
and "AllTeamMember"."teamId" = sub."teamId"`,
).run();
})();
}