Skip to content

Commit 8eacc92

Browse files
authored
feat: block filtering in participant form (#315)
* feat: block filtering in participant form * feat: add translations to attribute input block additonally, correct "noBlocksFound" translations * fix: make "no blocks found" a standard message
1 parent 221135c commit 8eacc92

5 files changed

Lines changed: 368 additions & 21 deletions

File tree

messages/en.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,17 @@
3131
"captchaInProgress": "CAPTCHA verification...",
3232
"captchaFailed": "CAPTCHA error. Try again",
3333
"emailIsRequiredTooltip": "You must provide an email address to register for this event.",
34-
"attributeIsRequiredTooltip": "This field is required."
34+
"attributeIsRequiredTooltip": "This field is required.",
35+
"searchBlocksLabel": "Search blocks",
36+
"searchBlocksPlaceholder": "Search blocks...",
37+
"hideFullBlocks": "Hide full",
38+
"noBlocksFound": "No matching blocks found.",
39+
"noBlockOption": "None",
40+
"noBlockOptionDescription": "(opt out or unregister)",
41+
"userRegisteredOnBlock": "You are already on this list.",
42+
"participants": "Participants",
43+
"noParticipants": "No participants",
44+
"anonymousParticipant": "Anonymous participant."
3545
},
3646
"Auth": {
3747
"registerTitle": "Organizer registration",

messages/pl.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,17 @@
3131
"captchaInProgress": "Weryfikacja CAPTCHA...",
3232
"captchaFailed": "Błąd CAPTCHA. Spróbuj ponownie",
3333
"emailIsRequiredTooltip": "Musisz podać adres e-mail, aby zarejestrować się na to wydarzenie.",
34-
"attributeIsRequiredTooltip": "To pole jest wymagane."
34+
"attributeIsRequiredTooltip": "To pole jest wymagane.",
35+
"searchBlocksLabel": "Wyszukaj bloki",
36+
"searchBlocksPlaceholder": "Wyszukaj bloki...",
37+
"hideFullBlocks": "Ukryj pełne",
38+
"noBlocksFound": "Nie znaleziono pasujących bloków.",
39+
"noBlockOption": "Żaden",
40+
"noBlockOptionDescription": "(zrezygnuj lub wypisz się)",
41+
"userRegisteredOnBlock": "Jesteś już na tej liście.",
42+
"participants": "Uczestnicy",
43+
"noParticipants": "Brak uczestników.",
44+
"anonymousParticipant": "Anonimowy uczestnik."
3545
},
3646
"Auth": {
3747
"registerTitle": "Rejestracja organizatora",

src/components/attribute-blocks-wrapper.tsx

Lines changed: 94 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,37 @@
11
"use client";
22

3+
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
4+
import { useTranslations } from "next-intl";
5+
import { Activity, useState } from "react";
36
import type { ControllerRenderProps, FieldValues } from "react-hook-form";
47

8+
import { cn } from "@/lib/utils";
59
import type { Attribute } from "@/types/attributes";
610
import type { PublicBlock } from "@/types/blocks";
711
import type { PublicParticipant } from "@/types/participant";
812

913
import { AttributeInputBlock } from "./attribute-input-block";
14+
import { Checkbox } from "./ui/checkbox";
15+
import { Field, FieldError, FieldGroup, FieldLabel } from "./ui/field";
1016
import { FormControl, FormItem, FormLabel } from "./ui/form";
17+
import { Input } from "./ui/input";
1118
import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
1219

20+
function includeBlock(
21+
block: PublicBlock,
22+
searchText: string,
23+
hideFullBlocks: boolean,
24+
): boolean {
25+
const doesBlockIncludeSearchText =
26+
searchText === "" ||
27+
block.name.toLowerCase().includes(searchText.toLowerCase());
28+
const isBlockNotFull =
29+
!hideFullBlocks ||
30+
block.capacity === null ||
31+
(block.meta.participantsInBlockCount ?? 0) < block.capacity;
32+
return doesBlockIncludeSearchText && isBlockNotFull;
33+
}
34+
1335
/**
1436
* This component wraps all block entries for a block type attribute.
1537
* It provides an accordion root element for block occupants accordion items.
@@ -26,31 +48,88 @@ export function AttributeBlocksWrapper({
2648
eventBlocks: PublicBlock[];
2749
attribute: Attribute;
2850
}) {
51+
const t = useTranslations("Form");
52+
const [searchText, setSearchText] = useDebouncedState("", {
53+
wait: 200,
54+
});
55+
const [hideFullBlocks, setHideFullBlocks] = useState(false);
56+
57+
const filteredBlocks = eventBlocks.filter((block) =>
58+
includeBlock(block, searchText, hideFullBlocks),
59+
);
2960
return (
3061
<div
31-
className={`mt-4 ${eventBlocks.length >= 3 ? "flex justify-center" : ""}`}
62+
className={cn(
63+
"mt-4",
64+
eventBlocks.length >= 3 && "flex flex-col items-center justify-center",
65+
)}
3266
>
67+
{eventBlocks.length >= 5 && (
68+
<FieldGroup className="mb-2 gap-2">
69+
<div className="flex w-full flex-col gap-4 md:flex-row">
70+
<Field>
71+
<Input
72+
aria-label={t("searchBlocksLabel")}
73+
onChange={(event) => {
74+
setSearchText(event.target.value.trim());
75+
}}
76+
placeholder={t("searchBlocksPlaceholder")}
77+
/>
78+
</Field>
79+
<Field orientation="horizontal" className="w-min">
80+
<Checkbox
81+
id="hide-full-checkbox"
82+
name="hide-full-checkbox"
83+
checked={hideFullBlocks}
84+
onCheckedChange={(checked) => {
85+
setHideFullBlocks(checked === true);
86+
}}
87+
/>
88+
<FieldLabel
89+
htmlFor="hide-full-checkbox"
90+
className="whitespace-nowrap"
91+
>
92+
{t("hideFullBlocks")}
93+
</FieldLabel>
94+
</Field>
95+
</div>
96+
<Activity mode={filteredBlocks.length === 0 ? "visible" : "hidden"}>
97+
<p className="text-muted-foreground text-sm font-normal">
98+
{t("noBlocksFound")}
99+
</p>
100+
</Activity>
101+
</FieldGroup>
102+
)}
33103
<RadioGroup
34104
onValueChange={field.onChange}
35105
defaultValue={String(field.value)}
36-
className={`mt-4 ${eventBlocks.length >= 3 ? "xl:min-w-xl xl:grid-cols-2" : ""}`}
106+
className={cn(
107+
"mt-4",
108+
eventBlocks.length >= 3 && "w-full xl:min-w-xl xl:grid-cols-2",
109+
)}
37110
>
38-
<FormItem className="flex flex-col rounded-md border border-slate-500 p-4 [&>button:first-of-type]:m-0">
39-
<div className="flex items-center gap-4">
40-
<FormControl>
41-
<RadioGroupItem value={"null"} />
42-
</FormControl>
43-
<FormLabel>
44-
<p>Żaden</p>
45-
</FormLabel>
46-
</div>
47-
<span className="h-14">(zrezygnuj lub wypisz się)</span>
48-
</FormItem>
49-
{eventBlocks.map((childBlock) => (
111+
<Activity
112+
mode={
113+
filteredBlocks.length === eventBlocks.length ? "visible" : "hidden"
114+
}
115+
>
116+
<FormItem className="flex flex-col rounded-md border border-slate-500 p-4 [&>button:first-of-type]:m-0">
117+
<div className="flex items-center gap-4">
118+
<FormControl>
119+
<RadioGroupItem value={"null"} />
120+
</FormControl>
121+
<FormLabel>
122+
<p>{t("noBlockOption")}</p>
123+
</FormLabel>
124+
</div>
125+
<span className="h-14">{t("noBlockOptionDescription")}</span>
126+
</FormItem>
127+
</Activity>
128+
{filteredBlocks.map((childBlock) => (
50129
<AttributeInputBlock
51130
userData={userData}
52131
block={childBlock}
53-
key={childBlock.name}
132+
key={childBlock.id}
54133
displayedAttributes={attribute.options ?? []}
55134
/>
56135
))}

src/components/attribute-input-block.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import { ChevronRight, Users } from "lucide-react";
4+
import { useTranslations } from "next-intl";
45

56
import { cn } from "@/lib/utils";
67
import type { PublicBlock } from "@/types/blocks";
@@ -29,6 +30,7 @@ export function AttributeInputBlock({
2930
userData: PublicParticipant;
3031
displayedAttributes: string[];
3132
}) {
33+
const t = useTranslations("Form");
3234
const isFull =
3335
block.capacity !== null && block.meta.participants.length >= block.capacity;
3436
const isRegistered = userData.attributes.some(
@@ -70,7 +72,7 @@ export function AttributeInputBlock({
7072
</div>
7173
{isRegistered ? (
7274
<p className="text-muted-foreground mb-0 text-sm leading-none whitespace-nowrap">
73-
Jesteś już na tej liście
75+
{t("userRegisteredOnBlock")}
7476
</p>
7577
) : null}
7678
<div className="mt-auto">
@@ -79,15 +81,17 @@ export function AttributeInputBlock({
7981
className="text-primary flex w-full items-center gap-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50 [&[data-state=open]>svg]:rotate-90"
8082
disabled={block.meta.participants.length === 0}
8183
>
82-
Uczestnicy
84+
{t("participants")}
8385
<ChevronRight className="size-4 transition-transform" />
8486
</PopoverTrigger>
8587
<PopoverContent
8688
className="w-[var(--radix-popover-trigger-width)] p-2"
8789
align="center"
8890
>
8991
{block.meta.participants.length === 0 ? (
90-
<p className="text-muted-foreground text-sm">Brak uczestników</p>
92+
<p className="text-muted-foreground text-sm">
93+
{t("noParticipants")}
94+
</p>
9195
) : (
9296
<ScrollArea className="[&_[data-slot=scroll-area-viewport]]:max-h-64">
9397
<ul className="divide-border/60 -mx-1 space-y-0.5 px-1">
@@ -104,7 +108,7 @@ export function AttributeInputBlock({
104108
}`
105109
: displayedAttributes.includes("email")
106110
? occupant.email
107-
: "Anonimowy uczestnik"}
111+
: t("anonymousParticipant")}
108112
</li>
109113
))}
110114
</ul>

0 commit comments

Comments
 (0)