Add event search to map pool templates

This commit is contained in:
Remmy Cat Stock
2022-10-26 18:00:28 +02:00
parent 0a85ad08d2
commit d1b1367651
11 changed files with 315 additions and 82 deletions

View File

@@ -4,7 +4,7 @@ import Fuse from "fuse.js";
import clsx from "clsx";
import type { Unpacked } from "~/utils/types";
import type { GearType, UserWithPlusTier } from "~/db/types";
import { useUsers } from "~/hooks/swr";
import { useAllEventsWithMapPools, useUsers } from "~/hooks/swr";
import { useTranslation } from "react-i18next";
import {
clothesGearIds,
@@ -15,6 +15,7 @@ import {
} from "~/modules/in-game-lists";
import { gearImageUrl, mainWeaponImageUrl } from "~/utils/urls";
import { Image } from "./Image";
import { type SerializedMapPoolEvent } from "~/routes/calendar/map-pool-events";
const MAX_RESULTS_SHOWN = 6;
@@ -36,6 +37,7 @@ interface ComboboxProps<T> {
initialValue?: ComboboxOption<T>;
clearsInputOnFocus?: boolean;
onChange?: (selectedOption?: ComboboxOption<T>) => void;
fullWidth?: boolean;
}
export function Combobox<T extends Record<string, string | null | number>>({
@@ -49,11 +51,16 @@ export function Combobox<T extends Record<string, string | null | number>>({
className,
id,
isLoading = false,
fullWidth = false,
}: ComboboxProps<T>) {
const [selectedOption, setSelectedOption] =
React.useState<Unpacked<typeof options>>();
const [lastSelectedOption, setLastSelectedOption] =
React.useState<Unpacked<typeof options>>();
const { t } = useTranslation();
const [selectedOption, setSelectedOption] = React.useState<
Unpacked<typeof options> | undefined
>(initialValue);
const [lastSelectedOption, setLastSelectedOption] = React.useState<
Unpacked<typeof options> | undefined
>(initialValue);
const [query, setQuery] = React.useState("");
React.useEffect(() => {
@@ -75,73 +82,85 @@ export function Combobox<T extends Record<string, string | null | number>>({
const noMatches = filteredOptions.length === 0;
const displayValue = (option: Unpacked<typeof options>) => {
return option?.label ?? "";
};
return (
<HeadlessCombobox
value={selectedOption}
onChange={(selected) => {
onChange?.(selected);
setSelectedOption(selected);
setLastSelectedOption(selected);
}}
name={inputName}
disabled={isLoading}
>
<HeadlessCombobox.Input
onFocus={() => {
if (clearsInputOnFocus) {
setSelectedOption(undefined);
}
<div className="combobox-wrapper">
<HeadlessCombobox
value={selectedOption}
onChange={(selected) => {
onChange?.(selected);
setSelectedOption(selected);
setLastSelectedOption(selected);
}}
onBlur={() => {
if (!selectedOption && clearsInputOnFocus) {
setSelectedOption(lastSelectedOption);
}
}}
onChange={(event) => setQuery(event.target.value)}
placeholder={isLoading ? "Loading..." : placeholder}
className={clsx("combobox-input", className)}
displayValue={(option) =>
(option as unknown as Unpacked<typeof options>)?.label ?? ""
}
data-cy={`${inputName}-combobox-input`}
id={id}
required={required}
/>
<HeadlessCombobox.Options
className={clsx("combobox-options", {
empty: noMatches,
hidden: !query,
})}
name={inputName}
disabled={!selectedOption && isLoading}
>
{noMatches ? (
<div className="combobox-no-matches">
No matches found <span className="combobox-emoji">🤔</span>
</div>
) : (
filteredOptions.map((option) => (
<HeadlessCombobox.Option
key={option.value}
value={option}
as={React.Fragment}
>
{({ active }) => (
<li className={clsx("combobox-item", { active })}>
{option.imgPath && (
<Image
alt=""
path={option.imgPath}
width={24}
height={24}
/>
)}
{option.label}
</li>
)}
</HeadlessCombobox.Option>
))
)}
</HeadlessCombobox.Options>
</HeadlessCombobox>
<HeadlessCombobox.Input
onFocus={() => {
if (clearsInputOnFocus) {
setSelectedOption(undefined);
}
}}
onBlur={() => {
if (!selectedOption && clearsInputOnFocus) {
setSelectedOption(lastSelectedOption);
}
}}
onChange={(event) => setQuery(event.target.value)}
placeholder={isLoading ? t("actions.loading") : placeholder}
className={clsx("combobox-input", className, {
fullWidth,
})}
// To make SSR prefill work in an uncontrolled component
defaultValue={initialValue ? displayValue(initialValue) : undefined}
displayValue={displayValue}
data-cy={`${inputName}-combobox-input`}
id={id}
required={required}
/>
<HeadlessCombobox.Options
className={clsx("combobox-options", {
empty: noMatches,
fullWidth,
hidden: !query,
})}
>
{isLoading ? (
<div className="combobox-no-matches">{t("actions.loading")}</div>
) : noMatches ? (
<div className="combobox-no-matches">
{t("forms.errors.noSearchMatches")}{" "}
<span className="combobox-emoji">🤔</span>
</div>
) : (
filteredOptions.map((option) => (
<HeadlessCombobox.Option
key={option.value}
value={option}
as={React.Fragment}
>
{({ active }) => (
<li className={clsx("combobox-item", { active })}>
{option.imgPath && (
<Image
alt=""
path={option.imgPath}
width={24}
height={24}
/>
)}
{option.label}
</li>
)}
</HeadlessCombobox.Option>
))
)}
</HeadlessCombobox.Options>
</HeadlessCombobox>
</div>
);
}
@@ -157,6 +176,7 @@ export function UserCombobox({
ComboboxProps<Pick<UserWithPlusTier, "discordId" | "plusTier">>,
"inputName" | "onChange" | "className" | "id" | "required"
> & { userIdsToOmit?: Set<number>; initialUserId?: number }) {
const { t } = useTranslation();
const { users, isLoading, isError } = useUsers();
const options = React.useMemo(() => {
@@ -181,9 +201,7 @@ export function UserCombobox({
if (isError) {
return (
<div className="text-sm text-error">
Something went wrong. Try reloading the page.
</div>
<div className="text-sm text-error">{t("errors.genericReload")}</div>
);
}
@@ -191,7 +209,7 @@ export function UserCombobox({
<Combobox
inputName={inputName}
options={options}
placeholder="Sendou#0043"
placeholder="Sendou#4059"
isLoading={isLoading}
initialValue={initialValue}
onChange={onChange}
@@ -288,3 +306,71 @@ export function GearCombobox({
/>
);
}
const mapPoolEventToOption = (
e: SerializedMapPoolEvent
): ComboboxOption<Pick<SerializedMapPoolEvent, "serializedMapPool">> => ({
serializedMapPool: e.serializedMapPool,
label: e.name,
value: e.id.toString(),
});
type MapPoolEventsComboboxProps = Pick<
ComboboxProps<Pick<SerializedMapPoolEvent, "serializedMapPool">>,
"inputName" | "className" | "id" | "required"
> & {
initialEvent?: SerializedMapPoolEvent;
onChange: (event?: SerializedMapPoolEvent) => void;
};
export function MapPoolEventsCombobox({
id,
required,
className,
inputName,
onChange,
initialEvent,
}: MapPoolEventsComboboxProps) {
const { t } = useTranslation();
const { events, isLoading, isError } = useAllEventsWithMapPools();
const options = React.useMemo(
() => (events ? events.map(mapPoolEventToOption) : []),
[events]
);
// this is important so that we don't trigger the reset to the initialEvent every time
const initialOption = React.useMemo(
() => initialEvent && mapPoolEventToOption(initialEvent),
[initialEvent]
);
if (isError) {
return (
<div className="text-sm text-error">{t("errors.genericReload")}</div>
);
}
return (
<Combobox
inputName={inputName}
options={isLoading && initialOption ? [initialOption] : options}
placeholder={t("actions.search")}
initialValue={initialOption}
onChange={(e) => {
onChange(
e && {
id: parseInt(e.value, 10),
name: e.label,
serializedMapPool: e.serializedMapPool,
}
);
}}
className={className}
id={id}
required={required}
isLoading={isLoading}
fullWidth
/>
);
}

View File

@@ -15,6 +15,10 @@ import { CrossIcon } from "./icons/Cross";
import { ArrowLongLeftIcon } from "./icons/ArrowLongLeft";
import * as React from "react";
import type { CalendarEvent } from "~/db/types";
import type { SerializedMapPoolEvent } from "~/routes/calendar/map-pool-events";
import { assertType } from "~/utils/types";
import { MapPoolEventsCombobox } from "./Combobox";
import { useOnce } from "~/hooks/useOnce";
export type MapPoolSelectorProps = {
mapPool: MapPool;
@@ -24,9 +28,8 @@ export type MapPoolSelectorProps = {
event?: Pick<CalendarEvent, "id" | "name">
) => void;
className?: string;
recentEvents?: Array<
Pick<CalendarEvent, "id" | "name"> & { serializedMapPool: string }
>;
recentEvents?: SerializedMapPoolEvent[];
initialEvent?: Pick<CalendarEvent, "id" | "name">;
};
export function MapPoolSelector({
@@ -35,11 +38,20 @@ export function MapPoolSelector({
handleRemoval,
className,
recentEvents,
initialEvent,
}: MapPoolSelectorProps) {
const { t } = useTranslation();
const [template, setTemplate] = React.useState<MapPoolTemplateValue>(
detectTemplate(mapPool)
initialEvent ? "event" : detectTemplate(mapPool)
);
const initialSerializedEvent: SerializedMapPoolEvent | undefined = useOnce(
() =>
initialEvent && {
...initialEvent,
serializedMapPool: mapPool.serialized,
}
);
const handleStageModesChange = (newMapPool: MapPool) => {
@@ -54,7 +66,8 @@ export function MapPoolSelector({
const handleTemplateChange = (template: MapPoolTemplateValue) => {
setTemplate(template);
if (template === "none") {
if (template === "none" || template === "event") {
return;
}
@@ -75,6 +88,8 @@ export function MapPoolSelector({
}
return;
}
assertType<never, typeof template>();
};
return (
@@ -101,6 +116,12 @@ export function MapPoolSelector({
handleChange={handleTemplateChange}
recentEvents={recentEvents}
/>
{template === "event" && (
<TemplateEventSelection
initialEvent={initialSerializedEvent}
handleEventChange={handleMapPoolChange}
/>
)}
</div>
<MapPoolStages
mapPool={mapPool}
@@ -279,7 +300,8 @@ const presetIds: MapModePresetId[] = ["ANARCHY", "ALL", ...modesShort];
type MapPoolTemplateValue =
| "none"
| `preset:${MapModePresetId}`
| `recent-event:${string}`;
| `recent-event:${string}`
| "event";
function detectTemplate(mapPool: MapPool): MapPoolTemplateValue {
for (const presetId of presetIds) {
@@ -313,6 +335,7 @@ function MapPoolTemplateSelect({
}}
>
<option value="none">{t("common:maps.template.none")}</option>
<option value="event">{t("common:maps.template.event")}</option>
<optgroup label={t("common:maps.template.presets")}>
{(["ANARCHY", "ALL"] as const).map((presetId) => (
<option key={presetId} value={`preset:${presetId}`}>
@@ -340,3 +363,37 @@ function MapPoolTemplateSelect({
</label>
);
}
type TemplateEventSelectionProps = {
handleEventChange: (
mapPool: MapPool,
event?: Pick<CalendarEvent, "id" | "name">
) => void;
initialEvent?: SerializedMapPoolEvent;
};
function TemplateEventSelection({
handleEventChange,
initialEvent,
}: TemplateEventSelectionProps) {
const { t } = useTranslation();
const id = React.useId();
return (
<label className="stack sm">
{t("maps.template.event")}
<MapPoolEventsCombobox
id={id}
inputName={id}
onChange={(e) => {
if (e) {
handleEventChange(new MapPool(e.serializedMapPool), {
id: e.id,
name: e.name,
});
}
}}
initialEvent={initialEvent}
/>
</label>
);
}

View File

@@ -0,0 +1,18 @@
select
"CalendarEvent"."id",
"CalendarEvent"."name",
json_group_array(
json_object(
'stageId',
"MapPoolMap"."stageId",
'mode',
"MapPoolMap"."mode"
)
) as "mapPool"
from
"CalendarEvent"
join "MapPoolMap" on "CalendarEvent"."id" = "MapPoolMap"."calendarEventId"
group by
"CalendarEvent"."id"
order by
"CalendarEvent"."id" desc

View File

@@ -37,6 +37,7 @@ import createMapPoolMapSql from "./createMapPoolMap.sql";
import deleteMapPoolMapsSql from "./deleteMapPoolMaps.sql";
import findMapPoolByEventIdSql from "./findMapPoolByEventId.sql";
import findRecentMapPoolsByAuthorIdSql from "./findRecentMapPoolsByAuthorId.sql";
import findAllEventsWithMapPoolsSql from "./findAllEventsWithMapPools.sql";
const createStm = sql.prepare(createSql);
const updateStm = sql.prepare(updateSql);
@@ -487,3 +488,18 @@ export function findRecentMapPoolsByAuthorId(
serializedMapPool: MapPool.serialize(JSON.parse(row.mapPool)),
}));
}
const findAllEventsWithMapPoolsStm = sql.prepare(findAllEventsWithMapPoolsSql);
export function findAllEventsWithMapPools() {
return (
findAllEventsWithMapPoolsStm.all() as Array<
Pick<CalendarEvent, "id" | "name"> & {
mapPool: string;
}
>
).map((row) => ({
id: row.id,
name: row.name,
serializedMapPool: MapPool.serialize(JSON.parse(row.mapPool)),
}));
}

View File

@@ -1,7 +1,10 @@
import useSWRImmutable from "swr/immutable";
import type { EventsWithMapPoolsLoaderData } from "~/routes/calendar/map-pool-events";
import type { UsersLoaderData } from "~/routes/users";
const ALL_USERS_ROUTE = "/users?_data=routes%2Fusers";
import {
GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE,
GET_ALL_USERS_ROUTE,
} from "~/utils/urls";
const fetcher = async (url: string) => {
const res = await fetch(url);
@@ -10,7 +13,7 @@ const fetcher = async (url: string) => {
export function useUsers() {
const { data, error } = useSWRImmutable<UsersLoaderData>(
ALL_USERS_ROUTE,
GET_ALL_USERS_ROUTE,
fetcher
);
@@ -20,3 +23,16 @@ export function useUsers() {
isError: error,
};
}
export function useAllEventsWithMapPools() {
const { data, error } = useSWRImmutable<EventsWithMapPoolsLoaderData>(
GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE,
fetcher
);
return {
events: data?.events,
isLoading: !error && !data,
isError: error,
};
}

View File

@@ -0,0 +1,12 @@
import { json, type SerializeFrom } from "@remix-run/node";
import { findAllEventsWithMapPools } from "~/db/models/calendar/queries.server";
export const loader = () => {
return json({
events: findAllEventsWithMapPools(),
});
};
export type EventsWithMapPoolsLoaderData = SerializeFrom<typeof loader>;
export type SerializedMapPoolEvent =
EventsWithMapPoolsLoaderData["events"][number];

View File

@@ -125,6 +125,7 @@ export default function MapListPage() {
mapPool={mapPool}
handleMapPoolChange={handleMapPoolChange}
recentEvents={data.recentEventsWithMapPools}
initialEvent={data.calendarEvent}
/>
)}
<a

View File

@@ -493,10 +493,18 @@ dialog::backdrop {
}
}
.combobox-wrapper {
position: relative;
}
.combobox-input {
width: 12rem;
}
.combobox-input.fullWidth {
width: 100%;
}
.combobox-options {
position: absolute;
z-index: 2;
@@ -510,6 +518,10 @@ dialog::backdrop {
padding-inline: 0;
}
.combobox-options.fullWidth {
width: 100%;
}
.combobox-options.empty {
padding-block: var(--s-1-5);
}

View File

@@ -48,6 +48,9 @@ export const COMMON_PREVIEW_IMAGE = "/img/layout/common-preview.png";
export const ERROR_GIRL_IMAGE_PATH = `/img/layout/error-girl`;
export const LOGO_PATH = `/img/layout/logo`;
export const GET_ALL_USERS_ROUTE = "/users";
export const GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE = "/calendar/map-pool-events";
interface UserLinkArgs {
discordId: User["discordId"];
customUrl?: User["customUrl"];

View File

@@ -34,8 +34,10 @@
"actions.delete": "Löschen",
"actions.loadMore": "Mehr laden",
"actions.close": "Schließen",
"actions.loading": "Lädt...",
"actions.clear": "Leeren",
"actions.selectAll": "Alle auswählen",
"actions.search": "Suchen",
"maps.createMapList": "Arenen-Liste erstellen",
"maps.halfSz": "50% Herrschaft",
@@ -43,6 +45,7 @@
"maps.tournamentMaplist": "Arenen-Liste für Turnier erstellen (maps.iplabs.ink)",
"maps.template": "Vorlage",
"maps.template.none": "Keine",
"maps.template.event": "Event",
"maps.template.presets": "Voreinstellungen",
"maps.template.yourRecentEvents": "Deine Events",
"maps.template.preset.ANARCHY": "Anarchie-Modi",
@@ -54,6 +57,9 @@
"forms.name": "Name",
"forms.description": "Beschreibung",
"forms.errors.title": "Diese Fehler müssen behoben werden",
"forms.errors.noSearchMatches": "Keine Suchergebnisse",
"errors.genericReload": "Etwas ist schiefgegangen. Versuche die Seite neu zu laden.",
"tag.name.BADGE": "Abzeichen-Preise",
"tag.name.SPECIAL": "Spezielle Regeln",

View File

@@ -36,8 +36,10 @@
"actions.loadMore": "Load more",
"actions.copyToClipboard": "Copy to clipboard",
"actions.close": "Close",
"actions.loading": "Loading...",
"actions.clear": "Clear",
"actions.selectAll": "Select All",
"actions.search": "Search",
"maps.createMapList": "Create map list",
"maps.halfSz": "50% SZ",
@@ -45,6 +47,7 @@
"maps.tournamentMaplist": "Create tournament map list (maps.iplabs.ink)",
"maps.template": "Template",
"maps.template.none": "None",
"maps.template.event": "Event",
"maps.template.presets": "Presets",
"maps.template.yourRecentEvents": "Recent Events",
"maps.template.preset.ANARCHY": "Anarchy Modes",
@@ -56,6 +59,9 @@
"forms.name": "Name",
"forms.description": "Description",
"forms.errors.title": "Following errors need to be fixed",
"forms.errors.noSearchMatches": "No matches found",
"errors.genericReload": "Something went wrong. Try reloading the page.",
"tag.name.BADGE": "Badge prizes",
"tag.name.SPECIAL": "Special rules",