Skip to content

Commit b469559

Browse files
authored
Allow multi-member session filters (#56)
1 parent dcf40c0 commit b469559

6 files changed

Lines changed: 106 additions & 58 deletions

File tree

app/sessions/_components/FilterBar.tsx

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ type Member = {
1111

1212
type Props = {
1313
q: string;
14-
memberId: string;
14+
memberIds: string[];
1515
month: string;
1616
pmin: string;
1717
pmax: string;
@@ -20,13 +20,13 @@ type Props = {
2020
};
2121

2222
/**
23-
* /sessions 상단 필터. GET form 으로 제출 → URL ?q=&member=&month=&pmin=&pmax= 갱신.
23+
* /sessions 상단 필터. GET form 으로 제출 → URL ?q=&member=&member=&month=&pmin=&pmax= 갱신.
2424
* Server-rendered, 별도 client component 없음. 필터 변경 시 cursor 는 자동으로 리셋
2525
* (form 이 cursor 를 hidden 으로 안 들고 가니까).
2626
*/
2727
export function FilterBar({
2828
q,
29-
memberId,
29+
memberIds,
3030
month,
3131
pmin,
3232
pmax,
@@ -53,24 +53,41 @@ export function FilterBar({
5353
/>
5454
</div>
5555

56-
<div className="flex flex-col gap-1">
57-
<Label htmlFor="f-member" className="text-xs">
56+
<fieldset className="flex flex-col gap-2 sm:col-span-2">
57+
<legend className="text-xs font-medium text-[#1d1d1f]">
5858
참여 멤버
59-
</Label>
60-
<select
61-
id="f-member"
62-
name="member"
63-
defaultValue={memberId}
64-
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
65-
>
66-
<option value="">전체</option>
67-
{members.map((m) => (
68-
<option key={m.id} value={m.id}>
69-
{m.name}
70-
</option>
71-
))}
72-
</select>
73-
</div>
59+
</legend>
60+
{members.length > 0 ? (
61+
<div className="flex flex-wrap gap-2">
62+
{members.map((m) => {
63+
const inputId = `f-member-${m.id}`;
64+
return (
65+
<div key={m.id} className="relative">
66+
<input
67+
id={inputId}
68+
name="member"
69+
type="checkbox"
70+
value={m.id}
71+
defaultChecked={memberIds.includes(m.id)}
72+
className="peer sr-only"
73+
/>
74+
<Label
75+
htmlFor={inputId}
76+
className="inline-flex min-h-9 cursor-pointer items-center rounded-full border border-[#e0e0e0] bg-white px-4 py-2 text-sm text-[#1d1d1f] transition-colors peer-checked:border-[#0066cc] peer-checked:text-[#0066cc] peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[#0071e3]"
77+
>
78+
{m.name}
79+
</Label>
80+
</div>
81+
);
82+
})}
83+
</div>
84+
) : (
85+
<p className="text-xs text-[#7a7a7a]">승인된 멤버가 없어요.</p>
86+
)}
87+
<p className="text-xs text-[#7a7a7a]">
88+
여러 명을 선택하면 모두 참여한 세션만 보여줘요.
89+
</p>
90+
</fieldset>
7491

7592
<div className="flex flex-col gap-1">
7693
<Label htmlFor="f-month" className="text-xs">

app/sessions/page.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type PageProps = {
2121
searchParams: Promise<SearchParams>;
2222
};
2323

24+
2425
export default async function SessionsArchivePage({ searchParams }: PageProps) {
2526
await requireApproved();
2627

@@ -53,11 +54,11 @@ export default async function SessionsArchivePage({ searchParams }: PageProps) {
5354
</header>
5455

5556
<FilterBar
56-
q={sp.q ?? ""}
57-
memberId={sp.member ?? ""}
58-
month={sp.month ?? ""}
59-
pmin={sp.pmin ?? ""}
60-
pmax={sp.pmax ?? ""}
57+
q={filters.q ?? ""}
58+
memberIds={filters.memberIds ?? []}
59+
month={filters.month ?? ""}
60+
pmin={filters.pmin != null ? String(filters.pmin) : ""}
61+
pmax={filters.pmax != null ? String(filters.pmax) : ""}
6162
members={members}
6263
hasActiveFilters={hasActiveFilters}
6364
/>

lib/session-filters.ts

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
1+
type SearchParamValue = string | string[] | undefined;
2+
13
export type SessionArchiveSearchParams = {
2-
cursor?: string;
3-
q?: string;
4-
member?: string;
5-
month?: string;
6-
pmin?: string;
7-
pmax?: string;
4+
cursor?: SearchParamValue;
5+
q?: SearchParamValue;
6+
member?: SearchParamValue;
7+
month?: SearchParamValue;
8+
pmin?: SearchParamValue;
9+
pmax?: SearchParamValue;
810
};
911

1012
export type ParsedSessionArchiveParams = {
1113
cursorId?: number;
1214
filters: {
1315
q?: string;
14-
memberId?: string;
16+
memberIds?: string[];
1517
month?: string;
1618
pmin?: number;
1719
pmax?: number;
@@ -22,20 +24,21 @@ export type ParsedSessionArchiveParams = {
2224
export function parseSessionArchiveParams(
2325
params: SessionArchiveSearchParams,
2426
): ParsedSessionArchiveParams {
27+
const memberIds = parseStringListParam(params.member);
2528
const filters = {
26-
q: params.q?.trim() || undefined,
27-
memberId: params.member?.trim() || undefined,
28-
month: parseMonthParam(params.month) ?? undefined,
29-
pmin: parseNonNegativeInt(params.pmin),
30-
pmax: parseNonNegativeInt(params.pmax),
29+
q: firstSearchParam(params.q)?.trim() || undefined,
30+
memberIds: memberIds.length > 0 ? memberIds : undefined,
31+
month: parseMonthParam(firstSearchParam(params.month)) ?? undefined,
32+
pmin: parseNonNegativeInt(firstSearchParam(params.pmin)),
33+
pmax: parseNonNegativeInt(firstSearchParam(params.pmax)),
3134
};
3235

3336
return {
34-
cursorId: parsePositiveInt(params.cursor),
37+
cursorId: parsePositiveInt(firstSearchParam(params.cursor)),
3538
filters,
3639
hasActiveFilters: Boolean(
3740
filters.q ||
38-
filters.memberId ||
41+
filters.memberIds?.length ||
3942
filters.month ||
4043
filters.pmin != null ||
4144
filters.pmax != null,
@@ -44,12 +47,15 @@ export function parseSessionArchiveParams(
4447
}
4548

4649
export function buildSessionArchiveHref(
47-
params: Record<string, string | undefined>,
50+
params: Record<string, SearchParamValue>,
4851
): string {
4952
const q = new URLSearchParams();
5053
for (const [key, value] of Object.entries(params)) {
51-
const trimmed = value?.trim();
52-
if (trimmed) q.set(key, trimmed);
54+
const values = Array.isArray(value) ? value : [value];
55+
for (const item of values) {
56+
const trimmed = item?.trim();
57+
if (trimmed) q.append(key, trimmed);
58+
}
5359
}
5460
const query = q.toString();
5561
return query ? `/sessions?${query}` : "/sessions";
@@ -72,6 +78,27 @@ export function parseMonthRange(month: string | undefined): {
7278
};
7379
}
7480

81+
function firstSearchParam(raw: SearchParamValue): string | undefined {
82+
if (Array.isArray(raw)) {
83+
return raw.find((value) => value.trim());
84+
}
85+
return raw;
86+
}
87+
88+
function parseStringListParam(raw: SearchParamValue): string[] {
89+
const values = Array.isArray(raw) ? raw : raw ? [raw] : [];
90+
const seen = new Set<string>();
91+
const parsed: string[] = [];
92+
93+
for (const value of values) {
94+
const trimmed = value.trim();
95+
if (!trimmed || seen.has(trimmed)) continue;
96+
seen.add(trimmed);
97+
parsed.push(trimmed);
98+
}
99+
100+
return parsed;
101+
}
75102
function parseMonthParam(raw: string | undefined): string | null {
76103
const value = raw?.trim();
77104
if (!value) return null;

lib/sessions.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,14 @@ export type SessionListPage = {
3232
/**
3333
* /sessions 아카이브 필터.
3434
* - q : 장소 substring (case-insensitive ASCII / 한글 그대로)
35-
* - memberId : 해당 user 가 *참여한* 세션만
35+
* - memberIds : 선택한 모든 user 가 *참여한* 세션만
3636
* - month : "YYYY-MM" — 해당 달의 세션만 (UTC 기준; 동아리는 KST 라 거의 일치)
3737
* - pmin/pmax : 참여 인원 수 범위. Prisma 가 relation _count where 를 직접
3838
* 지원 안 해서 raw 로 ID 만 따로 추출한 뒤 IN 으로 필터.
3939
*/
4040
export type SessionFilters = {
4141
q?: string;
42-
memberId?: string;
42+
memberIds?: string[];
4343
month?: string;
4444
pmin?: number;
4545
pmax?: number;
@@ -75,10 +75,12 @@ function buildWhere(
7575
if (filters?.q && filters.q.trim()) {
7676
where.location = { contains: filters.q.trim() };
7777
}
78-
if (filters?.memberId) {
79-
where.participations = {
80-
some: { userId: filters.memberId },
81-
};
78+
if (filters?.memberIds?.length) {
79+
where.AND = filters.memberIds.map((userId) => ({
80+
participations: {
81+
some: { userId },
82+
},
83+
}));
8284
}
8385
const range = parseMonthRange(filters?.month);
8486
if (range) {

test/form-and-filter.test.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ describe("FilterBar", () => {
4343
render(
4444
<FilterBar
4545
q="서울숲"
46-
memberId="u2"
46+
memberIds={["u1", "u2"]}
4747
month="2026-06"
4848
pmin="2"
4949
pmax="8"
@@ -56,9 +56,8 @@ describe("FilterBar", () => {
5656
);
5757

5858
expect(screen.getByLabelText("장소")).toHaveValue("서울숲");
59-
expect(screen.getByRole("combobox", { name: "참여 멤버" })).toHaveValue(
60-
"u2",
61-
);
59+
expect(screen.getByRole("checkbox", { name: "민지" })).toBeChecked();
60+
expect(screen.getByRole("checkbox", { name: "현민" })).toBeChecked();
6261
expect(screen.getByLabelText("월")).toHaveValue("2026-06");
6362
expect(screen.getByLabelText("참여 인원 최소")).toHaveValue(2);
6463
expect(screen.getByLabelText("참여 인원 최대")).toHaveValue(8);
@@ -72,7 +71,7 @@ describe("FilterBar", () => {
7271
render(
7372
<FilterBar
7473
q=""
75-
memberId=""
74+
memberIds={[]}
7675
month=""
7776
pmin=""
7877
pmax=""

test/session-filters.test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ describe("session archive filter parsing", () => {
1111
const parsed = parseSessionArchiveParams({
1212
cursor: "12",
1313
q: " 서울숲 ",
14-
member: "user-1",
14+
member: ["user-1", " user-2 ", "user-1", ""],
1515
month: "2026-06",
1616
pmin: "2",
1717
pmax: "10",
@@ -21,7 +21,7 @@ describe("session archive filter parsing", () => {
2121
cursorId: 12,
2222
filters: {
2323
q: "서울숲",
24-
memberId: "user-1",
24+
memberIds: ["user-1", "user-2"],
2525
month: "2026-06",
2626
pmin: 2,
2727
pmax: 10,
@@ -42,7 +42,7 @@ describe("session archive filter parsing", () => {
4242
cursorId: undefined,
4343
filters: {
4444
q: undefined,
45-
memberId: undefined,
45+
memberIds: undefined,
4646
month: undefined,
4747
pmin: undefined,
4848
pmax: undefined,
@@ -64,10 +64,12 @@ describe("session archive filter parsing", () => {
6464
expect(
6565
buildSessionArchiveHref({
6666
q: "서울숲",
67-
member: "",
67+
member: ["u1", "", "u2"],
6868
month: "2026-06",
6969
cursor: "25",
7070
}),
71-
).toBe("/sessions?q=%EC%84%9C%EC%9A%B8%EC%88%B2&month=2026-06&cursor=25");
71+
).toBe(
72+
"/sessions?q=%EC%84%9C%EC%9A%B8%EC%88%B2&member=u1&member=u2&month=2026-06&cursor=25",
73+
);
7274
});
7375
});

0 commit comments

Comments
 (0)