mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 15:17:43 -05:00
Team member ordering
This commit is contained in:
@@ -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}
|
||||
)
|
||||
`,
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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> {
|
||||
|
||||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user