diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 09c8da35a..e9dabf548 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -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} ) `, ) diff --git a/app/db/tables.ts b/app/db/tables.ts index ecd880a34..f2a02b9b4 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -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; teamId: number; userId: number; isMainTeam: DBBoolean; diff --git a/app/features/team/TeamRepository.server.ts b/app/features/team/TeamRepository.server.ts index 893667633..01b4451ad 100644 --- a/app/features/team/TeamRepository.server.ts +++ b/app/features/team/TeamRepository.server.ts @@ -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`-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) diff --git a/app/features/team/actions/t.$customUrl.roster.server.ts b/app/features/team/actions/t.$customUrl.roster.server.ts index 709651c3a..167f4a9ee 100644 --- a/app/features/team/actions/t.$customUrl.roster.server.ts +++ b/app/features/team/actions/t.$customUrl.roster.server.ts @@ -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, diff --git a/app/features/team/routes/t.$customUrl.roster.tsx b/app/features/team/routes/t.$customUrl.roster.tsx index cd252ad99..7d5685004 100644 --- a/app/features/team/routes/t.$customUrl.roster.tsx +++ b/app/features/team/routes/t.$customUrl.roster.tsx @@ -147,7 +147,10 @@ function MemberActions() { className="stack md-plus" data-testid={`member-row-${index}`} > -
+
{member ? : null} {member?.username}
diff --git a/app/features/team/team-schemas.ts b/app/features/team/team-schemas.ts index 23f0301cd..61b120448 100644 --- a/app/features/team/team-schemas.ts +++ b/app/features/team/team-schemas.ts @@ -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(), diff --git a/app/form/SendouForm.browser.test.tsx b/app/form/SendouForm.browser.test.tsx index fdcf1a64e..fa3bb6abd 100644 --- a/app/form/SendouForm.browser.test.tsx +++ b/app/form/SendouForm.browser.test.tsx @@ -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. diff --git a/app/form/fields/ArrayFormField.module.css b/app/form/fields/ArrayFormField.module.css index a80b65a89..36ea747e1 100644 --- a/app/form/fields/ArrayFormField.module.css +++ b/app/form/fields/ArrayFormField.module.css @@ -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); diff --git a/app/form/fields/ArrayFormField.tsx b/app/form/fields/ArrayFormField.tsx index 27799e1f7..6f449031a 100644 --- a/app/form/fields/ArrayFormField.tsx +++ b/app/form/fields/ArrayFormField.tsx @@ -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)?._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 (
{translatedLabel ? (
{translatedLabel}
) : null} - {Array.from({ length: visibleCount }).map((_, idx) => - isObjectArray ? ( - handleRemoveAt(idx)} - sortable={sortable} - > - {renderItem(idx, `${name}[${idx}]`)} - - ) : ( -
-
+ {isObjectArray + ? Array.from({ length: visibleCount }).map((_, idx) => ( + handleRemoveAt(idx)} + sortable={isSortable} + canMoveUp={idx > 0} + canMoveDown={idx < count - 1} + onMoveUp={() => handleMoveAt(idx, -1)} + onMoveDown={() => handleMoveAt(idx, 1)} + > {renderItem(idx, `${name}[${idx}]`)} + + )) + : Array.from({ length: visibleCount }).map((_, idx) => ( +
+
+ {renderItem(idx, `${name}[${idx}]`)} +
+ {canRemoveAt(idx) ? ( + } + aria-label="Remove item" + size="small" + variant="minimal-destructive" + onPress={() => handleRemoveAt(idx)} + className={styles.removeButton} + /> + ) : null}
- {canRemoveAt(idx) ? ( - } - aria-label="Remove item" - size="small" - variant="minimal-destructive" - onPress={() => handleRemoveAt(idx)} - className={styles.removeButton} - /> - ) : null} -
- ), - )} + ))} {translatedError ? ( {translatedError} ) : 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 (
- {sortable ? : null} #{index + 1} + {sortable ? ( + <> + } + aria-label="Move down" + size="small" + variant="minimal" + onPress={onMoveDown} + isDisabled={!canMoveDown} + /> + } + aria-label="Move up" + size="small" + variant="minimal" + onPress={onMoveUp} + isDisabled={!canMoveUp} + /> + + ) : null} 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 extends FormFieldBase { diff --git a/db-test.sqlite3 b/db-test.sqlite3 index b661f602c..f6b2f5d6e 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/seeds/db-seed-AB_RR.sqlite3 b/e2e/seeds/db-seed-AB_RR.sqlite3 index 8419f10a1..59efa13d8 100644 Binary files a/e2e/seeds/db-seed-AB_RR.sqlite3 and b/e2e/seeds/db-seed-AB_RR.sqlite3 differ diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index 4753efaf7..28aac23b9 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 index b96f8dbe0..5dc58a85c 100644 Binary files a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 and b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 differ diff --git a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 index 0aeeedd60..155d00f4f 100644 Binary files a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 and b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index 18d67fe09..95b1b688d 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index 8abc05633..0423e3335 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index b37ec626f..6ee68595e 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index 451620a80..16acdca1a 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index 70be4df49..345578b39 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index d9da850cd..fd768aa5e 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ diff --git a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 index 9ca59bd9a..a2b7f6525 100644 Binary files a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 and b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 differ diff --git a/e2e/team.spec.ts b/e2e/team.spec.ts index c59a16250..bd7b6b545 100644 --- a/e2e/team.spec.ts +++ b/e2e/team.spec.ts @@ -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); diff --git a/migrations/150-team-member-custom-roles.js b/migrations/150-team-member-custom-roles.js index 6bcc66f8a..7e706bb3c 100644 --- a/migrations/150-team-member-custom-roles.js +++ b/migrations/150-team-member-custom-roles.js @@ -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(); })(); }