Edit managers frontend

This commit is contained in:
Kalle 2022-07-07 11:23:16 +03:00
parent 67c8b2c449
commit bb96f631fe
6 changed files with 184 additions and 20 deletions

View File

@ -14,6 +14,7 @@ interface ComboboxProps<T> {
options: ComboboxOption<T>[];
inputName: string;
placeholder: string;
className?: string;
isLoading?: boolean;
onChange?: (selectedOption?: ComboboxOption<T>) => void;
}
@ -23,6 +24,7 @@ export function Combobox<T extends Record<string, string | null | number>>({
inputName,
placeholder,
onChange,
className,
isLoading = false,
}: ComboboxProps<T>) {
const [selectedOption, setSelectedOption] =
@ -56,7 +58,7 @@ export function Combobox<T extends Record<string, string | null | number>>({
<HeadlessCombobox.Input
onChange={(event) => setQuery(event.target.value)}
placeholder={isLoading ? "Loading..." : placeholder}
className="combobox-input"
className={clsx("combobox-input", className)}
displayValue={(option) =>
(option as Unpacked<typeof options>)?.label ?? ""
}
@ -96,10 +98,12 @@ export function Combobox<T extends Record<string, string | null | number>>({
export function UserCombobox({
inputName,
onChange,
userIdsToOmit,
className,
}: Pick<
ComboboxProps<Pick<UserWithPlusTier, "discordId" | "plusTier">>,
"inputName" | "onChange"
>) {
"inputName" | "onChange" | "className"
> & { userIdsToOmit?: Set<number> }) {
const fetcher = useFetcher<UsersLoaderData>();
React.useEffect(() => {
@ -108,22 +112,29 @@ export function UserCombobox({
const isLoading = fetcher.type !== "done";
const options = () => {
if (isLoading) return [];
const data = userIdsToOmit
? fetcher.data.users.filter((user) => !userIdsToOmit.has(user.id))
: fetcher.data.users;
return data.map((u) => ({
label: u.discordFullName,
value: String(u.id),
discordId: u.discordId,
plusTier: u.plusTier,
}));
};
return (
<Combobox
inputName={inputName}
options={
isLoading
? []
: fetcher.data.users.map((u) => ({
label: u.discordFullName,
value: String(u.id),
discordId: u.discordId,
plusTier: u.plusTier,
}))
}
options={options()}
placeholder="Sendou#0043"
isLoading={isLoading}
onChange={onChange}
className={className}
/>
);
}

View File

@ -8,17 +8,18 @@ import type { ManagersByBadgeId } from "./db/models/badges.server";
// TODO: 1) move "root checkers" to one file and utils to one file 2) make utils const for more terseness
export function canPerformAdminActions(user?: Pick<User, "discordId">) {
type IsAdminUser = Pick<User, "discordId">;
function isAdmin(user?: IsAdminUser) {
return user?.discordId === ADMIN_DISCORD_ID;
}
export function canPerformAdminActions(user?: IsAdminUser) {
if (["development", "test"].includes(process.env.NODE_ENV)) return true;
return isAdmin(user);
}
function isAdmin(user?: Pick<User, "discordId">) {
return user?.discordId === ADMIN_DISCORD_ID;
}
function adminOverride(user?: Pick<User, "discordId">) {
function adminOverride(user?: IsAdminUser) {
if (isAdmin(user)) {
return () => true;
}
@ -248,3 +249,7 @@ function isBadgeManager({
if (!user) return false;
return managers.some((manager) => manager.id === user.id);
}
export function canEditBadgeManagers(user?: IsAdminUser) {
return isAdmin(user);
}

View File

@ -1,6 +1,6 @@
import { json } from "@remix-run/node";
import type { LoaderFunction } from "@remix-run/node";
import { useLoaderData, useMatches, useParams } from "@remix-run/react";
import { Outlet, useLoaderData, useMatches, useParams } from "@remix-run/react";
import clsx from "clsx";
import { Badge } from "~/components/Badge";
import { LinkButton } from "~/components/Button";
@ -17,6 +17,10 @@ import { discordFullName } from "~/utils/strings";
import { BADGES_PAGE } from "~/utils/urls";
import type { BadgesLoaderData } from "../badges";
export interface BadgeDetailsContext {
badgeName: string;
}
export interface BadgeDetailsLoaderData {
owners: OwnersByBadgeId;
managers: ManagersByBadgeId;
@ -44,8 +48,11 @@ export default function BadgeDetailsPage() {
const badge = badges.find((b) => b.id === Number(params["id"]));
if (!badge) return <Redirect to={BADGES_PAGE} />;
const context: BadgeDetailsContext = { badgeName: badge.displayName };
return (
<div className="stack md items-center">
<Outlet context={context} />
<Badge badge={badge} isAnimated size={200} />
<div className="badges__explanation">{badgeExplanationText(badge)}</div>
{canEditBadgeOwners({ user, managers: data.managers }) ? (

View File

@ -0,0 +1,113 @@
import * as React from "react";
import { useMatches, useOutletContext } from "@remix-run/react";
import { Button, LinkButton } from "~/components/Button";
import { Dialog } from "~/components/Dialog";
import { atOrError } from "~/utils/arrays";
import type { BadgeDetailsContext, BadgeDetailsLoaderData } from "../$id";
import { discordFullName } from "~/utils/strings";
import { UserCombobox } from "~/components/Combobox";
import { TrashIcon } from "~/components/icons/Trash";
// xxx: on SSR modal flickers first shown at top
export default function EditBadgePage() {
const matches = useMatches();
const data = atOrError(matches, -2).data as BadgeDetailsLoaderData;
const { badgeName } = useOutletContext<BadgeDetailsContext>();
return (
<Dialog isOpen className="stack md">
<div>
<h2 className="badges-edit__big-header">
Editing winners of {badgeName}
</h2>
<LinkButton
to={atOrError(matches, -2).pathname}
variant="minimal-destructive"
tiny
>
Cancel
</LinkButton>
</div>
<Managers data={data} />
<Owners data={data} />
</Dialog>
);
}
function Managers({ data }: { data: BadgeDetailsLoaderData }) {
const [managers, setManagers] = React.useState(
data.managers.map((m) => ({
id: m.id,
discordFullName: discordFullName(m),
}))
);
const amountOfChanges = managers
.filter((m) => !data.managers.some((om) => om.id === m.id))
// maps to id to keep typescript happy
.map((m) => m.id)
// needed so we can also list amount of removed managers
.concat(
data.managers
.filter((om) => !managers.some((m) => m.id === om.id))
.map((m) => m.id)
).length;
return (
<div className="stack md">
<div className="stack sm">
<h3 className="badges-edit__small-header">Managers</h3>
<ul className="badges-edit__users-list">
{managers.map((manager) => (
<li key={manager.id}>
{manager.discordFullName}
<Button
icon={<TrashIcon />}
variant="minimal-destructive"
aria-label="Delete badge manager"
onClick={() =>
setManagers(managers.filter((m) => m.id !== manager.id))
}
/>
</li>
))}
</ul>
<UserCombobox
className="mx-auto"
inputName="new-manager"
onChange={(user) => {
if (!user) return;
setManagers([
...managers,
{ discordFullName: user.label, id: Number(user.value) },
]);
}}
userIdsToOmit={new Set(managers.map((m) => m.id))}
/>
</div>
<Button
type="submit"
tiny
className="w-full"
disabled={amountOfChanges === 0}
>
{amountOfChanges > 0 ? `Submit ${amountOfChanges} changes` : "Submit"}
</Button>
</div>
);
}
function Owners({ data }: { data: BadgeDetailsLoaderData }) {
return (
<div>
<h3 className="badges-edit__small-header">Owners</h3>
<div className="stack vertical md">
<Button type="submit" tiny>
Submit
</Button>
</div>
</div>
);
}

View File

@ -52,3 +52,27 @@
color: var(--theme-vibrant);
font-size: var(--fonts-xs);
}
.badges-edit__users-list > li {
list-style: none;
display: flex;
justify-content: space-between;
align-items: center;
}
.badges-edit__users-list {
padding: 0;
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
display: flex;
gap: var(--s-2);
flex-direction: column;
}
.badges-edit__big-header {
font-size: var(--fonts-lg);
}
.badges-edit__small-header {
font-size: var(--fonts-md);
}

View File

@ -217,6 +217,10 @@
margin-inline-start: auto;
}
.mx-auto {
margin: 0 auto;
}
.hidden {
display: none;
}