Skip to content

Commit 46e5af3

Browse files
authored
Merge pull request #46 from pythonkr/feature/add-filter-to-admin-api
feat: 어드민의 리소스 목록 페이지에 필터 기능 추가
2 parents 9d7d394 + d7ef946 commit 46e5af3

8 files changed

Lines changed: 302 additions & 2 deletions

File tree

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { Add, Clear, FilterList, RestartAlt } from "@mui/icons-material";
2+
import { Box, Button, Chip, FormControl, IconButton, InputLabel, MenuItem, Select, Stack, TextField } from "@mui/material";
3+
import * as React from "react";
4+
5+
import BackendAdminAPISchemas from "../../../../../packages/common/src/schemas/backendAdminAPI";
6+
7+
type OpenAPIParameterSchema = BackendAdminAPISchemas.OpenAPIParameterSchema;
8+
type ChoicesResponse = BackendAdminAPISchemas.ChoicesResponse;
9+
10+
type AdminListFilterProps = {
11+
parameters: OpenAPIParameterSchema[];
12+
values: Record<string, string>;
13+
choices?: ChoicesResponse;
14+
onApply: (values: Record<string, string>) => void;
15+
};
16+
17+
export const AdminListFilter: React.FC<AdminListFilterProps> = ({ parameters, values, choices, onApply }) => {
18+
const [localValues, setLocalValues] = React.useState<Record<string, string>>(values);
19+
20+
React.useEffect(() => {
21+
setLocalValues(values);
22+
}, [values]);
23+
24+
const handleChange = (name: string, value: string) => {
25+
setLocalValues((prev) => ({ ...prev, [name]: value }));
26+
};
27+
28+
const handleApply = () => {
29+
const cleaned = Object.fromEntries(Object.entries(localValues).filter(([, v]) => v !== ""));
30+
onApply(cleaned);
31+
};
32+
33+
const handleClear = () => {
34+
setLocalValues({});
35+
onApply({});
36+
};
37+
38+
if (parameters.length === 0) return null;
39+
40+
return (
41+
<Box sx={{ mb: 2 }}>
42+
<Stack spacing={2} sx={{ p: 2, border: 1, borderColor: "divider", borderRadius: 1 }}>
43+
<Stack direction="row" alignItems="center" spacing={1}>
44+
<FilterList fontSize="small" />
45+
<span>필터</span>
46+
</Stack>
47+
<Stack direction="row" spacing={2} sx={{ flexWrap: "wrap", alignItems: "flex-start" }}>
48+
{parameters.map((param) => (
49+
<FilterField
50+
key={param.name}
51+
param={param}
52+
value={localValues[param.name] || ""}
53+
choices={choices?.[param.name]}
54+
onChange={handleChange}
55+
/>
56+
))}
57+
</Stack>
58+
<Stack direction="row" spacing={1}>
59+
<Button variant="outlined" onClick={handleApply} size="small">
60+
적용
61+
</Button>
62+
<Button variant="text" onClick={handleClear} size="small" startIcon={<RestartAlt />}>
63+
초기화
64+
</Button>
65+
</Stack>
66+
</Stack>
67+
</Box>
68+
);
69+
};
70+
71+
type ChoiceItem = { const: string | null; title: string };
72+
73+
type FilterFieldProps = {
74+
param: OpenAPIParameterSchema;
75+
value: string;
76+
choices?: ChoiceItem[];
77+
onChange: (name: string, value: string) => void;
78+
};
79+
80+
const FilterField: React.FC<FilterFieldProps> = ({ param, value, choices, onChange }) => {
81+
const { name, schema, description } = param;
82+
83+
if (schema?.type === "array") return <ArrayFilterField name={name} items={schema.items} value={value} onChange={onChange} />;
84+
if (schema?.enum) return <EnumFilterField name={name} options={schema.enum} value={value} onChange={onChange} />;
85+
86+
if (choices && choices.length > 0) {
87+
return (
88+
<FormControl size="small" sx={{ minWidth: 200 }}>
89+
<InputLabel>{name}</InputLabel>
90+
<Select value={value} label={name} onChange={(e) => onChange(name, e.target.value as string)}>
91+
<MenuItem value="">
92+
<em>전체</em>
93+
</MenuItem>
94+
{choices.map((choice) => (
95+
<MenuItem key={choice.const ?? "__null__"} value={choice.const ?? ""}>
96+
{choice.title}
97+
</MenuItem>
98+
))}
99+
</Select>
100+
</FormControl>
101+
);
102+
}
103+
104+
const inputType = schema?.type === "integer" || schema?.type === "number" ? "number" : "text";
105+
const helperText = schema?.format === "uuid" ? "UUID" : description || undefined;
106+
107+
return (
108+
<TextField
109+
label={name}
110+
value={value}
111+
onChange={(e) => onChange(name, e.target.value)}
112+
size="small"
113+
type={inputType}
114+
helperText={helperText}
115+
sx={{ minWidth: 200 }}
116+
/>
117+
);
118+
};
119+
120+
type EnumFilterFieldProps = {
121+
name: string;
122+
options: string[];
123+
value: string;
124+
onChange: (name: string, value: string) => void;
125+
};
126+
127+
const EnumFilterField: React.FC<EnumFilterFieldProps> = ({ name, options, value, onChange }) => {
128+
const selectedValues = value ? value.split(",") : [];
129+
130+
const handleChange = (newValues: string | string[]) => {
131+
const arr = typeof newValues === "string" ? newValues.split(",") : newValues;
132+
onChange(name, arr.filter((v) => v !== "").join(","));
133+
};
134+
135+
return (
136+
<FormControl size="small" sx={{ minWidth: 200 }}>
137+
<InputLabel>{name}</InputLabel>
138+
<Select
139+
multiple
140+
value={selectedValues}
141+
label={name}
142+
onChange={(e) => handleChange(e.target.value)}
143+
renderValue={(selected) => (
144+
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
145+
{selected.map((v) => (
146+
<Chip key={v} label={v} size="small" />
147+
))}
148+
</Box>
149+
)}
150+
>
151+
{options.map((opt) => (
152+
<MenuItem key={opt} value={opt}>
153+
{opt}
154+
</MenuItem>
155+
))}
156+
</Select>
157+
</FormControl>
158+
);
159+
};
160+
161+
type ArrayFilterFieldProps = {
162+
name: string;
163+
items?: { type?: string; enum?: string[] };
164+
value: string;
165+
onChange: (name: string, value: string) => void;
166+
};
167+
168+
const ArrayFilterField: React.FC<ArrayFilterFieldProps> = ({ name, items, value, onChange }) => {
169+
const values = value ? value.split(",") : [];
170+
171+
const updateValues = (newValues: string[]) => onChange(name, newValues.filter((v) => v !== "").join(","));
172+
const handleAdd = () => updateValues([...values, ""]);
173+
const handleRemove = (index: number) => updateValues(values.filter((_, i) => i !== index));
174+
175+
const handleItemChange = (index: number, newValue: string) => {
176+
const newValues = [...values];
177+
newValues[index] = newValue;
178+
updateValues(newValues);
179+
};
180+
181+
const inputType = items?.type === "integer" || items?.type === "number" ? "number" : "text";
182+
183+
return (
184+
<Box sx={{ minWidth: 200 }}>
185+
<Stack spacing={1}>
186+
<Stack direction="row" alignItems="center" spacing={1}>
187+
<InputLabel sx={{ fontSize: "0.875rem" }}>{name}</InputLabel>
188+
<IconButton size="small" onClick={handleAdd}>
189+
<Add fontSize="small" />
190+
</IconButton>
191+
</Stack>
192+
{values.map((v, index) => (
193+
<Stack key={index} direction="row" spacing={0.5} alignItems="center">
194+
{items?.enum ? (
195+
<FormControl size="small" sx={{ minWidth: 150 }}>
196+
<Select value={v} onChange={(e) => handleItemChange(index, e.target.value as string)} displayEmpty>
197+
<MenuItem value="">
198+
<em>선택</em>
199+
</MenuItem>
200+
{items.enum.map((opt) => (
201+
<MenuItem key={opt} value={opt}>
202+
{opt}
203+
</MenuItem>
204+
))}
205+
</Select>
206+
</FormControl>
207+
) : (
208+
<TextField value={v} onChange={(e) => handleItemChange(index, e.target.value)} size="small" type={inputType} sx={{ minWidth: 150 }} />
209+
)}
210+
<IconButton size="small" onClick={() => handleRemove(index)}>
211+
<Clear fontSize="small" />
212+
</IconButton>
213+
</Stack>
214+
))}
215+
</Stack>
216+
</Box>
217+
);
218+
};

apps/pyconkr-admin/src/components/layouts/admin_editor.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,21 @@ const InnerAdminEditor: React.FC<AppResourceIdType & AdminEditorPropsType> = Err
277277
});
278278
const backendAdminClient = Common.Hooks.BackendAdminAPI.useBackendAdminClient();
279279
const { data: schemaInfo } = Common.Hooks.BackendAdminAPI.useSchemaQuery(backendAdminClient, app, resource);
280+
const { data: choicesData } = Common.Hooks.BackendAdminAPI.useChoicesQuery(backendAdminClient, app, resource);
281+
282+
// Merge choices into schema for FK/M2M fields
283+
React.useMemo(() => {
284+
if (!choicesData || !schemaInfo.schema.properties) return;
285+
for (const [fieldName, items] of Object.entries(choicesData)) {
286+
const prop = (schemaInfo.schema.properties as Record<string, RJSFSchema>)[fieldName];
287+
if (!prop) continue;
288+
if (prop.type === "array" && prop.items) {
289+
(prop.items as RJSFSchema).oneOf = items;
290+
} else {
291+
prop.oneOf = items;
292+
}
293+
}
294+
}, [choicesData, schemaInfo.schema]);
280295

281296
const setTab = (_: React.SyntheticEvent, tab: number) => setEditorState((ps) => ({ ...ps, tab }));
282297
const setFormData = (formData?: Record<string, string>) => setEditorState((ps) => ({ ...ps, formData }));

apps/pyconkr-admin/src/components/layouts/admin_list.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import { Add } from "@mui/icons-material";
33
import { Box, Button, CircularProgress, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from "@mui/material";
44
import { ErrorBoundary, Suspense } from "@suspensive/react";
55
import * as React from "react";
6-
import { Link, useNavigate } from "react-router-dom";
6+
import { Link, useNavigate, useSearchParams } from "react-router-dom";
77

8+
import { AdminListFilter } from "../elements/admin_list_filter";
89
import { BackendAdminSignInGuard } from "../elements/admin_signin_guard";
910

1011
type AdminListProps = {
@@ -26,15 +27,29 @@ const InnerAdminList: React.FC<AdminListProps> = ErrorBoundary.with(
2627
{ fallback: Common.Components.ErrorFallback },
2728
Suspense.with({ fallback: <CircularProgress /> }, ({ app, resource, hideCreatedAt, hideUpdatedAt, hideCreateNew }) => {
2829
const navigate = useNavigate();
30+
const [searchParams, setSearchParams] = useSearchParams();
2931
const backendAdminClient = Common.Hooks.BackendAdminAPI.useBackendAdminClient();
30-
const listQuery = Common.Hooks.BackendAdminAPI.useListQuery<ListRowType>(backendAdminClient, app, resource);
32+
33+
const filterParams: Record<string, string> = Object.fromEntries(searchParams.entries());
34+
const listQuery = Common.Hooks.BackendAdminAPI.useListQuery<ListRowType>(backendAdminClient, app, resource, filterParams);
35+
36+
const openApiSchemaQuery = Common.Hooks.BackendAdminAPI.useOpenApiSchemaQuery(backendAdminClient);
37+
const queryParameters = React.useMemo(
38+
() => Common.Utils.extractQueryParameters(openApiSchemaQuery.data, app, resource),
39+
[openApiSchemaQuery.data, app, resource]
40+
);
41+
42+
const choicesQuery = Common.Hooks.BackendAdminAPI.useChoicesQuery(backendAdminClient, app, resource);
43+
44+
const handleFilterApply = (newParams: Record<string, string>) => setSearchParams(newParams, { replace: true });
3145

3246
return (
3347
<Stack sx={{ flexGrow: 1, width: "100%", minHeight: "100%" }}>
3448
<Typography variant="h5">
3549
{app.toUpperCase()} &gt; {resource.toUpperCase()} &gt; 목록
3650
</Typography>
3751
<br />
52+
<AdminListFilter parameters={queryParameters} values={filterParams} choices={choicesQuery.data} onApply={handleFilterApply} />
3853
<Box>
3954
{!hideCreateNew && (
4055
<Button variant="contained" onClick={() => navigate(`/${app}/${resource}/create`)} startIcon={<Add />}>

packages/common/src/apis/admin_api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ namespace BackendAdminAPIs {
5858
export const schema = (client: BackendAPIClient, app: string, resource: string) => () =>
5959
client.get<BackendAdminAPISchemas.AdminSchemaDefinition>(`v1/admin-api/${app}/${resource}/json-schema/`);
6060

61+
export const choices = (client: BackendAPIClient, app: string, resource: string) => () =>
62+
client.get<BackendAdminAPISchemas.ChoicesResponse>(`v1/admin-api/${app}/${resource}/choices/`);
63+
64+
export const openApiSchema = (client: BackendAPIClient) => () =>
65+
client.get<BackendAdminAPISchemas.OpenAPISchema>("api/schema/v1/", { params: { format: "json" } });
66+
6167
export const uploadPublicFile = (client: BackendAPIClient) => (file: File) => {
6268
const formData = new FormData();
6369
formData.append("file", file);

packages/common/src/hooks/useAdminAPI.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ const QUERY_KEYS = {
1010
ADMIN_LIST: ["query", "admin", "list"],
1111
ADMIN_RETRIEVE: ["query", "admin", "retrieve"],
1212
ADMIN_SCHEMA: ["query", "admin", "schema"],
13+
ADMIN_CHOICES: ["query", "admin", "choices"],
14+
ADMIN_OPENAPI_SCHEMA: ["query", "admin", "openapi-schema"],
1315
ADMIN_PREVIEW_MODIFICATION_AUDIT: ["query", "admin", "retrieve", "modification-audit"],
1416
};
1517

@@ -67,6 +69,19 @@ namespace BackendAdminAPIHooks {
6769
queryFn: BackendAdminAPIs.schema(client, app, resource),
6870
});
6971

72+
export const useChoicesQuery = (client: BackendAPIClient, app: string, resource: string) =>
73+
useSuspenseQuery({
74+
queryKey: [...QUERY_KEYS.ADMIN_CHOICES, app, resource],
75+
queryFn: BackendAdminAPIs.choices(client, app, resource),
76+
});
77+
78+
export const useOpenApiSchemaQuery = (client: BackendAPIClient) =>
79+
useSuspenseQuery({
80+
queryKey: QUERY_KEYS.ADMIN_OPENAPI_SCHEMA,
81+
queryFn: BackendAdminAPIs.openApiSchema(client),
82+
staleTime: Infinity,
83+
});
84+
7085
export const useListQuery = <T>(client: BackendAPIClient, app: string, resource: string, params?: Record<string, string>) =>
7186
useSuspenseQuery({
7287
queryKey: [...QUERY_KEYS.ADMIN_LIST, app, resource, JSON.stringify(params)],

packages/common/src/schemas/backendAdminAPI.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ namespace BackendAdminAPISchemas {
1818
translation_fields: string[];
1919
};
2020

21+
export type ChoicesResponse = Record<string, { const: string | null; title: string }[]>;
22+
2123
export type UserSchema = {
2224
id: number;
2325
username: string;
@@ -133,6 +135,21 @@ namespace BackendAdminAPISchemas {
133135
original: T;
134136
modified: T;
135137
};
138+
139+
export type OpenAPIParameterSchema = {
140+
name: string;
141+
in: "query" | "path" | "header" | "cookie";
142+
required?: boolean;
143+
description?: string;
144+
schema?: {
145+
type?: string;
146+
format?: string;
147+
items?: { type?: string; enum?: string[] };
148+
enum?: string[];
149+
};
150+
};
151+
152+
export type OpenAPISchema = { paths: Record<string, { get?: { parameters?: OpenAPIParameterSchema[] } }> };
136153
}
137154

138155
export default BackendAdminAPISchemas;

packages/common/src/utils/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
filterReadOnlyPropertiesInJsonSchema as _filterReadOnlyPropertiesInJsonSchema,
77
filterWritablePropertiesInJsonSchema as _filterWritablePropertiesInJsonSchema,
88
} from "./json_schema";
9+
import { extractQueryParameters as _extractQueryParameters } from "./openapi";
910
import { isFilledString as _isFilledString, isValidHttpUrl as _isValidHttpUrl, rtrim as _rtrim } from "./string";
1011

1112
namespace Utils {
@@ -21,6 +22,7 @@ namespace Utils {
2122
export const filterWritablePropertiesInJsonSchema = _filterWritablePropertiesInJsonSchema;
2223
export const filterReadOnlyPropertiesInJsonSchema = _filterReadOnlyPropertiesInJsonSchema;
2324
export const filterPropertiesByLanguageInJsonSchema = _filterPropertiesByLanguageInJsonSchema;
25+
export const extractQueryParameters = _extractQueryParameters;
2426
}
2527

2628
export default Utils;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import BackendAdminAPISchemas from "../schemas/backendAdminAPI";
2+
3+
export const extractQueryParameters = (
4+
schema: BackendAdminAPISchemas.OpenAPISchema,
5+
app: string,
6+
resource: string
7+
): BackendAdminAPISchemas.OpenAPIParameterSchema[] => {
8+
const pathItem = schema.paths[`/v1/admin-api/${app}/${resource}/`];
9+
if (!pathItem?.get?.parameters) return [];
10+
11+
return pathItem.get.parameters.filter((param) => param.in === "query");
12+
};

0 commit comments

Comments
 (0)