Skip to content

Commit 63a205b

Browse files
committed
refactor: json schema 관련 최적화 적용
1 parent 9d7d394 commit 63a205b

7 files changed

Lines changed: 244 additions & 2 deletions

File tree

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

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

Lines changed: 15 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,27 @@ 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 handleFilterApply = (newParams: Record<string, string>) => setSearchParams(newParams, { replace: true });
3143

3244
return (
3345
<Stack sx={{ flexGrow: 1, width: "100%", minHeight: "100%" }}>
3446
<Typography variant="h5">
3547
{app.toUpperCase()} &gt; {resource.toUpperCase()} &gt; 목록
3648
</Typography>
3749
<br />
50+
<AdminListFilter parameters={queryParameters} values={filterParams} onApply={handleFilterApply} />
3851
<Box>
3952
{!hideCreateNew && (
4053
<Button variant="contained" onClick={() => navigate(`/${app}/${resource}/create`)} startIcon={<Add />}>

packages/common/src/apis/admin_api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ 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 openApiSchema = (client: BackendAPIClient) => () =>
62+
client.get<BackendAdminAPISchemas.OpenAPISchema>("api/schema/v1/", { params: { format: "json" } });
63+
6164
export const uploadPublicFile = (client: BackendAPIClient) => (file: File) => {
6265
const formData = new FormData();
6366
formData.append("file", file);

packages/common/src/hooks/useAdminAPI.ts

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

@@ -67,6 +68,13 @@ namespace BackendAdminAPIHooks {
6768
queryFn: BackendAdminAPIs.schema(client, app, resource),
6869
});
6970

71+
export const useOpenApiSchemaQuery = (client: BackendAPIClient) =>
72+
useSuspenseQuery({
73+
queryKey: QUERY_KEYS.ADMIN_OPENAPI_SCHEMA,
74+
queryFn: BackendAdminAPIs.openApiSchema(client),
75+
staleTime: Infinity,
76+
});
77+
7078
export const useListQuery = <T>(client: BackendAPIClient, app: string, resource: string, params?: Record<string, string>) =>
7179
useSuspenseQuery({
7280
queryKey: [...QUERY_KEYS.ADMIN_LIST, app, resource, JSON.stringify(params)],

packages/common/src/schemas/backendAdminAPI.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,21 @@ namespace BackendAdminAPISchemas {
133133
original: T;
134134
modified: T;
135135
};
136+
137+
export type OpenAPIParameterSchema = {
138+
name: string;
139+
in: "query" | "path" | "header" | "cookie";
140+
required?: boolean;
141+
description?: string;
142+
schema?: {
143+
type?: string;
144+
format?: string;
145+
items?: { type?: string; enum?: string[] };
146+
enum?: string[];
147+
};
148+
};
149+
150+
export type OpenAPISchema = { paths: Record<string, { get?: { parameters?: OpenAPIParameterSchema[] } }> };
136151
}
137152

138153
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)