Sortable list UI

This commit is contained in:
Kalle (Sendou)
2021-12-09 09:07:03 +02:00
parent 36c42c1980
commit e888fef7c6
5 changed files with 335 additions and 101 deletions

View File

@@ -0,0 +1,191 @@
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import classNames from "classnames";
import * as React from "react";
import { useFetcher, useMatches } from "remix";
import { Alert } from "~/components/Alert";
import { Button } from "~/components/Button";
import { MyForm } from "~/components/MyForm";
import { TOURNAMENT_TEAM_ROSTER_MIN_SIZE } from "~/constants";
import { checkInHasStarted } from "~/core/tournament/utils";
import type { FindTournamentByNameForUrlI } from "~/services/tournament";
import type { Unpacked } from "~/utils";
export function AdminTeamControls() {
const [, parentRoute] = useMatches();
const { teams, checkInStartTime } =
parentRoute.data as FindTournamentByNameForUrlI;
const [teamOrder, setTeamOrder] = React.useState(teams.map((t) => t.id));
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const teamsSorted = teams.sort(
(a, b) => teamOrder.indexOf(a.id) - teamOrder.indexOf(b.id)
);
return (
<>
<Alert type="info" className="tournament__admin__alert">
Drag teams to adjust their seeding
</Alert>
<ul className="tournament__admin__teams-container">
<li className="tournament__admin__teams-list-row">
<div className="tournament__admin__teams-container__header">Seed</div>
<div className="tournament__admin__teams-container__header">Name</div>
<div className="tournament__admin__teams-container__header">
{checkInHasStarted(checkInStartTime) ? "" : "Registered at"}
</div>
<div className="tournament__admin__teams-container__header">
Roster size
</div>
</li>
{/* TODO: order by seed */}
<DndContext
id="team-seed-sorter"
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => {
const { active, over } = event;
if (!over) return;
if (active.id !== over.id) {
setTeamOrder((teamIds) => {
const oldIndex = teamIds.indexOf(active.id);
const newIndex = teamIds.indexOf(over.id);
return arrayMove(teamIds, oldIndex, newIndex);
});
}
}}
>
<SortableContext
items={teamOrder}
strategy={verticalListSortingStrategy}
>
{teamsSorted.map((team, i) => (
<SortableRow key={team.id} team={team} seed={i + 1} />
))}
</SortableContext>
</DndContext>
</ul>
</>
);
}
function SortableRow({
team,
seed,
}: {
team: Unpacked<FindTournamentByNameForUrlI["teams"]>;
seed: number;
}) {
const [, parentRoute] = useMatches();
const { checkInStartTime } = parentRoute.data as FindTournamentByNameForUrlI;
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: team.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<li
className="tournament__admin__teams-list-row sortable"
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
>
<div>{seed}</div>
<div>{team.name}</div>
<div>
{!checkInHasStarted(checkInStartTime) ? (
<>
{new Date(team.createdAt).toLocaleString("en-US", {
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
})}
</>
) : team.checkedInTime ? (
<CheckOutButton teamId={team.id} />
) : team.members.length >= TOURNAMENT_TEAM_ROSTER_MIN_SIZE ? (
<CheckInButton teamId={team.id} />
) : null}
</div>
<div
className={classNames({
tournament__admin__ok:
team.members.length >= TOURNAMENT_TEAM_ROSTER_MIN_SIZE,
tournament__admin__problem:
team.members.length < TOURNAMENT_TEAM_ROSTER_MIN_SIZE,
})}
>
{team.members.length}
</div>
</li>
);
}
function CheckOutButton({ teamId }: { teamId: string }) {
const fetcher = useFetcher();
return (
<MyForm
action={`/api/tournament/${teamId}/check-out`}
className="tournament__action-section__button-container"
fetcher={fetcher}
>
<Button
tiny
variant="minimal-destructive"
loading={fetcher.state !== "idle"}
loadingText="Checking out"
type="submit"
>
Check-out
</Button>
</MyForm>
);
}
function CheckInButton({ teamId }: { teamId: string }) {
const fetcher = useFetcher();
return (
<MyForm
action={`/api/tournament/${teamId}/check-in`}
className="tournament__action-section__button-container"
fetcher={fetcher}
>
<Button
tiny
variant="minimal"
loading={fetcher.state !== "idle"}
loadingText="Checking in"
type="submit"
>
Check-in
</Button>
</MyForm>
);
}

View File

@@ -1,118 +1,20 @@
import { useFetcher, useMatches } from "remix";
import type { LinksFunction } from "remix";
import { Button } from "~/components/Button";
import type { FindTournamentByNameForUrlI } from "~/services/tournament";
import { AdminTeamControls } from "~/components/tournament/AdminTeamControls";
import styles from "~/styles/tournament-admin.css";
import * as React from "react";
import classNames from "classnames";
import { TOURNAMENT_TEAM_ROSTER_MIN_SIZE } from "~/constants";
import { Alert } from "~/components/Alert";
import { MyForm } from "~/components/MyForm";
import { checkInHasStarted } from "~/core/tournament/utils";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export default function AdminPage() {
const [, parentRoute] = useMatches();
const { teams, checkInStartTime } =
parentRoute.data as FindTournamentByNameForUrlI;
return (
<>
<div className="tournament__admin__buttons-container">
<Button>Start tournament</Button>
<Button variant="outlined">Edit tournament</Button>
</div>
<Alert type="info" className="tournament__admin__alert">
Drag teams to adjust their seeding
</Alert>
<div className="tournament__admin__teams-container">
<div className="tournament__admin__teams-container__header">Seed</div>
<div className="tournament__admin__teams-container__header">Name</div>
<div className="tournament__admin__teams-container__header">
{checkInHasStarted(checkInStartTime) ? "" : "Registered at"}
</div>
<div className="tournament__admin__teams-container__header">
Roster size
</div>
{/* TODO: order by seed */}
{teams.map((team, i) => (
<React.Fragment key={team.id}>
<div>{i + 1}</div>
<div>{team.name}</div>
<div>
{!checkInHasStarted(checkInStartTime) ? (
<>
{new Date(team.createdAt).toLocaleString("en-US", {
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
})}
</>
) : team.checkedInTime ? (
<CheckOutButton teamId={team.id} />
) : team.members.length >= TOURNAMENT_TEAM_ROSTER_MIN_SIZE ? (
<CheckInButton teamId={team.id} />
) : null}
</div>
<div
className={classNames({
tournament__admin__ok:
team.members.length >= TOURNAMENT_TEAM_ROSTER_MIN_SIZE,
tournament__admin__problem:
team.members.length < TOURNAMENT_TEAM_ROSTER_MIN_SIZE,
})}
>
{team.members.length}
</div>
</React.Fragment>
))}
</div>
<AdminTeamControls />
</>
);
}
function CheckOutButton({ teamId }: { teamId: string }) {
const fetcher = useFetcher();
return (
<MyForm
action={`/api/tournament/${teamId}/check-out`}
className="tournament__action-section__button-container"
fetcher={fetcher}
>
<Button
tiny
variant="minimal-destructive"
loading={fetcher.state !== "idle"}
loadingText="Checking out"
type="submit"
>
Check-out
</Button>
</MyForm>
);
}
function CheckInButton({ teamId }: { teamId: string }) {
const fetcher = useFetcher();
return (
<MyForm
action={`/api/tournament/${teamId}/check-in`}
className="tournament__action-section__button-container"
fetcher={fetcher}
>
<Button
tiny
variant="minimal"
loading={fetcher.state !== "idle"}
loadingText="Checking in"
type="submit"
>
Check-in
</Button>
</MyForm>
);
}

View File

@@ -11,16 +11,21 @@
}
/* TODO: overflow-x scroll */
.tournament__admin__teams-container {
.tournament__admin__teams-list-row {
display: grid;
width: 100%;
column-gap: var(--s-1);
font-size: var(--fonts-sm);
grid-template-columns: 1fr 3fr 3fr 1fr;
list-style: none;
margin-block-start: var(--s-4);
row-gap: var(--s-1-5);
}
.tournament__admin__teams-list-row.sortable {
cursor: grab;
}
.tournament__admin__teams-container__header {
font-weight: var(--bold);
}

134
package-lock.json generated
View File

@@ -7,6 +7,8 @@
"name": "remix-app-template",
"hasInstallScript": true,
"dependencies": {
"@dnd-kit/core": "^4.0.3",
"@dnd-kit/sortable": "^5.1.0",
"@prisma/client": "^3.6.0",
"@remix-run/express": "^1.0.6",
"@remix-run/react": "^1.0.6",
@@ -195,6 +197,75 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.0.0.tgz",
"integrity": "sha512-QwaQ1IJHQHMMuAGOOYHQSx7h7vMZPfO97aDts8t5N/MY7n2QTDSnW+kF7uRQ1tVBkr6vJ+BqHWG5dlgGvwVjow==",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/accessibility/node_modules/tslib": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
},
"node_modules/@dnd-kit/core": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-4.0.3.tgz",
"integrity": "sha512-uT1uHZxKx3iEkupmLfknMIvbykMJSetoXXmra6sGGvtWy+OMKrWm3axH2c90+JC/q6qaeKs2znd3Qs8GLnCa5Q==",
"dependencies": {
"@dnd-kit/accessibility": "^3.0.0",
"@dnd-kit/utilities": "^3.0.1",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core/node_modules/tslib": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
},
"node_modules/@dnd-kit/sortable": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-5.1.0.tgz",
"integrity": "sha512-CPyiUHbTrSYzhddfgdeoX0ERg/dEyVKIWx9+4O6uqpoppo84SXCBHVFiFBRVpQ9wtpsXs7prtUAnAUTcvFQTZg==",
"dependencies": {
"@dnd-kit/utilities": "^3.0.0",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^4.0.2",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable/node_modules/tslib": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
},
"node_modules/@dnd-kit/utilities": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.0.1.tgz",
"integrity": "sha512-lAfK9lS5H9I1B/t3iVkQa85Cn+nPRJh3ksPUxAnoxPDqvwcW4tfV0wP++O/dRCP30T76+YF+/XJo7hyOEkzHig==",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities/node_modules/tslib": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
},
"node_modules/@gar/promisify": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.2.tgz",
@@ -8218,6 +8289,69 @@
}
}
},
"@dnd-kit/accessibility": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.0.0.tgz",
"integrity": "sha512-QwaQ1IJHQHMMuAGOOYHQSx7h7vMZPfO97aDts8t5N/MY7n2QTDSnW+kF7uRQ1tVBkr6vJ+BqHWG5dlgGvwVjow==",
"requires": {
"tslib": "^2.0.0"
},
"dependencies": {
"tslib": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
}
}
},
"@dnd-kit/core": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-4.0.3.tgz",
"integrity": "sha512-uT1uHZxKx3iEkupmLfknMIvbykMJSetoXXmra6sGGvtWy+OMKrWm3axH2c90+JC/q6qaeKs2znd3Qs8GLnCa5Q==",
"requires": {
"@dnd-kit/accessibility": "^3.0.0",
"@dnd-kit/utilities": "^3.0.1",
"tslib": "^2.0.0"
},
"dependencies": {
"tslib": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
}
}
},
"@dnd-kit/sortable": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-5.1.0.tgz",
"integrity": "sha512-CPyiUHbTrSYzhddfgdeoX0ERg/dEyVKIWx9+4O6uqpoppo84SXCBHVFiFBRVpQ9wtpsXs7prtUAnAUTcvFQTZg==",
"requires": {
"@dnd-kit/utilities": "^3.0.0",
"tslib": "^2.0.0"
},
"dependencies": {
"tslib": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
}
}
},
"@dnd-kit/utilities": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.0.1.tgz",
"integrity": "sha512-lAfK9lS5H9I1B/t3iVkQa85Cn+nPRJh3ksPUxAnoxPDqvwcW4tfV0wP++O/dRCP30T76+YF+/XJo7hyOEkzHig==",
"requires": {
"tslib": "^2.0.0"
},
"dependencies": {
"tslib": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
}
}
},
"@gar/promisify": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.2.tgz",

View File

@@ -22,6 +22,8 @@
"cy:run": "npx cypress run"
},
"dependencies": {
"@dnd-kit/core": "^4.0.3",
"@dnd-kit/sortable": "^5.1.0",
"@prisma/client": "^3.6.0",
"@remix-run/express": "^1.0.6",
"@remix-run/react": "^1.0.6",