Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 37 additions & 20 deletions app/sessions/_components/FilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ type Member = {

type Props = {
q: string;
memberId: string;
memberIds: string[];
month: string;
pmin: string;
pmax: string;
Expand All @@ -20,13 +20,13 @@ type Props = {
};

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

<div className="flex flex-col gap-1">
<Label htmlFor="f-member" className="text-xs">
<fieldset className="flex flex-col gap-2 sm:col-span-2">
<legend className="text-xs font-medium text-[#1d1d1f]">
참여 멤버
</Label>
<select
id="f-member"
name="member"
defaultValue={memberId}
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"
>
<option value="">전체</option>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</div>
</legend>
{members.length > 0 ? (
<div className="flex flex-wrap gap-2">
{members.map((m) => {
const inputId = `f-member-${m.id}`;
return (
<div key={m.id} className="relative">
<input
id={inputId}
name="member"
type="checkbox"
value={m.id}
defaultChecked={memberIds.includes(m.id)}
className="peer sr-only"
/>
<Label
htmlFor={inputId}
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]"
>
{m.name}
</Label>
</div>
);
})}
</div>
) : (
<p className="text-xs text-[#7a7a7a]">승인된 멤버가 없어요.</p>
)}
<p className="text-xs text-[#7a7a7a]">
여러 명을 선택하면 모두 참여한 세션만 보여줘요.
</p>
</fieldset>

<div className="flex flex-col gap-1">
<Label htmlFor="f-month" className="text-xs">
Expand Down
11 changes: 6 additions & 5 deletions app/sessions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type PageProps = {
searchParams: Promise<SearchParams>;
};


export default async function SessionsArchivePage({ searchParams }: PageProps) {
await requireApproved();

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

<FilterBar
q={sp.q ?? ""}
memberId={sp.member ?? ""}
month={sp.month ?? ""}
pmin={sp.pmin ?? ""}
pmax={sp.pmax ?? ""}
q={filters.q ?? ""}
memberIds={filters.memberIds ?? []}
month={filters.month ?? ""}
pmin={filters.pmin != null ? String(filters.pmin) : ""}
pmax={filters.pmax != null ? String(filters.pmax) : ""}
members={members}
hasActiveFilters={hasActiveFilters}
/>
Expand Down
61 changes: 44 additions & 17 deletions lib/session-filters.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
type SearchParamValue = string | string[] | undefined;

export type SessionArchiveSearchParams = {
cursor?: string;
q?: string;
member?: string;
month?: string;
pmin?: string;
pmax?: string;
cursor?: SearchParamValue;
q?: SearchParamValue;
member?: SearchParamValue;
month?: SearchParamValue;
pmin?: SearchParamValue;
pmax?: SearchParamValue;
};

export type ParsedSessionArchiveParams = {
cursorId?: number;
filters: {
q?: string;
memberId?: string;
memberIds?: string[];
month?: string;
pmin?: number;
pmax?: number;
Expand All @@ -22,20 +24,21 @@ export type ParsedSessionArchiveParams = {
export function parseSessionArchiveParams(
params: SessionArchiveSearchParams,
): ParsedSessionArchiveParams {
const memberIds = parseStringListParam(params.member);
const filters = {
q: params.q?.trim() || undefined,
memberId: params.member?.trim() || undefined,
month: parseMonthParam(params.month) ?? undefined,
pmin: parseNonNegativeInt(params.pmin),
pmax: parseNonNegativeInt(params.pmax),
q: firstSearchParam(params.q)?.trim() || undefined,
memberIds: memberIds.length > 0 ? memberIds : undefined,
month: parseMonthParam(firstSearchParam(params.month)) ?? undefined,
pmin: parseNonNegativeInt(firstSearchParam(params.pmin)),
pmax: parseNonNegativeInt(firstSearchParam(params.pmax)),
};

return {
cursorId: parsePositiveInt(params.cursor),
cursorId: parsePositiveInt(firstSearchParam(params.cursor)),
filters,
hasActiveFilters: Boolean(
filters.q ||
filters.memberId ||
filters.memberIds?.length ||
filters.month ||
filters.pmin != null ||
filters.pmax != null,
Expand All @@ -44,12 +47,15 @@ export function parseSessionArchiveParams(
}

export function buildSessionArchiveHref(
params: Record<string, string | undefined>,
params: Record<string, SearchParamValue>,
): string {
const q = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
const trimmed = value?.trim();
if (trimmed) q.set(key, trimmed);
const values = Array.isArray(value) ? value : [value];
for (const item of values) {
const trimmed = item?.trim();
if (trimmed) q.append(key, trimmed);
}
}
const query = q.toString();
return query ? `/sessions?${query}` : "/sessions";
Expand All @@ -72,6 +78,27 @@ export function parseMonthRange(month: string | undefined): {
};
}

function firstSearchParam(raw: SearchParamValue): string | undefined {
if (Array.isArray(raw)) {
return raw.find((value) => value.trim());
}
return raw;
}

function parseStringListParam(raw: SearchParamValue): string[] {
const values = Array.isArray(raw) ? raw : raw ? [raw] : [];
const seen = new Set<string>();
const parsed: string[] = [];

for (const value of values) {
const trimmed = value.trim();
if (!trimmed || seen.has(trimmed)) continue;
seen.add(trimmed);
parsed.push(trimmed);
}

return parsed;
}
function parseMonthParam(raw: string | undefined): string | null {
const value = raw?.trim();
if (!value) return null;
Expand Down
14 changes: 8 additions & 6 deletions lib/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ export type SessionListPage = {
/**
* /sessions 아카이브 필터.
* - q : 장소 substring (case-insensitive ASCII / 한글 그대로)
* - memberId : 해당 user 가 *참여한* 세션만
* - memberIds : 선택한 모든 user 가 *참여한* 세션만
* - month : "YYYY-MM" — 해당 달의 세션만 (UTC 기준; 동아리는 KST 라 거의 일치)
* - pmin/pmax : 참여 인원 수 범위. Prisma 가 relation _count where 를 직접
* 지원 안 해서 raw 로 ID 만 따로 추출한 뒤 IN 으로 필터.
*/
export type SessionFilters = {
q?: string;
memberId?: string;
memberIds?: string[];
month?: string;
pmin?: number;
pmax?: number;
Expand Down Expand Up @@ -75,10 +75,12 @@ function buildWhere(
if (filters?.q && filters.q.trim()) {
where.location = { contains: filters.q.trim() };
}
if (filters?.memberId) {
where.participations = {
some: { userId: filters.memberId },
};
if (filters?.memberIds?.length) {
where.AND = filters.memberIds.map((userId) => ({
participations: {
some: { userId },
},
}));
}
const range = parseMonthRange(filters?.month);
if (range) {
Expand Down
9 changes: 4 additions & 5 deletions test/form-and-filter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe("FilterBar", () => {
render(
<FilterBar
q="서울숲"
memberId="u2"
memberIds={["u1", "u2"]}
month="2026-06"
pmin="2"
pmax="8"
Expand All @@ -56,9 +56,8 @@ describe("FilterBar", () => {
);

expect(screen.getByLabelText("장소")).toHaveValue("서울숲");
expect(screen.getByRole("combobox", { name: "참여 멤버" })).toHaveValue(
"u2",
);
expect(screen.getByRole("checkbox", { name: "민지" })).toBeChecked();
expect(screen.getByRole("checkbox", { name: "현민" })).toBeChecked();
expect(screen.getByLabelText("월")).toHaveValue("2026-06");
expect(screen.getByLabelText("참여 인원 최소")).toHaveValue(2);
expect(screen.getByLabelText("참여 인원 최대")).toHaveValue(8);
Expand All @@ -72,7 +71,7 @@ describe("FilterBar", () => {
render(
<FilterBar
q=""
memberId=""
memberIds={[]}
month=""
pmin=""
pmax=""
Expand Down
12 changes: 7 additions & 5 deletions test/session-filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ describe("session archive filter parsing", () => {
const parsed = parseSessionArchiveParams({
cursor: "12",
q: " 서울숲 ",
member: "user-1",
member: ["user-1", " user-2 ", "user-1", ""],
month: "2026-06",
pmin: "2",
pmax: "10",
Expand All @@ -21,7 +21,7 @@ describe("session archive filter parsing", () => {
cursorId: 12,
filters: {
q: "서울숲",
memberId: "user-1",
memberIds: ["user-1", "user-2"],
month: "2026-06",
pmin: 2,
pmax: 10,
Expand All @@ -42,7 +42,7 @@ describe("session archive filter parsing", () => {
cursorId: undefined,
filters: {
q: undefined,
memberId: undefined,
memberIds: undefined,
month: undefined,
pmin: undefined,
pmax: undefined,
Expand All @@ -64,10 +64,12 @@ describe("session archive filter parsing", () => {
expect(
buildSessionArchiveHref({
q: "서울숲",
member: "",
member: ["u1", "", "u2"],
month: "2026-06",
cursor: "25",
}),
).toBe("/sessions?q=%EC%84%9C%EC%9A%B8%EC%88%B2&month=2026-06&cursor=25");
).toBe(
"/sessions?q=%EC%84%9C%EC%9A%B8%EC%88%B2&member=u1&member=u2&month=2026-06&cursor=25",
);
});
});
Loading