Skip to content

Commit e0d2576

Browse files
committed
fix: merge commit
2 parents 235fe79 + 46e5af3 commit e0d2576

9 files changed

Lines changed: 371 additions & 59 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: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
import { Components } from "@frontend/common";
2-
import { useBackendAdminClient, useCreateMutation, useRemoveMutation, useSchemaQuery, useUpdateMutation } from "@frontend/common/src/hooks/useAdminAPI";
3-
import { filterPropertiesByLanguageInJsonSchema, filterReadOnlyPropertiesInJsonSchema, filterWritablePropertiesInJsonSchema } from "@frontend/common/src/utils";
42
import { retrieve } from "@frontend/common/src/apis/admin_api";
3+
import {
4+
useBackendAdminClient,
5+
useChoicesQuery,
6+
useCreateMutation,
7+
useRemoveMutation,
8+
useSchemaQuery,
9+
useUpdateMutation,
10+
} from "@frontend/common/src/hooks/useAdminAPI";
11+
import {
12+
filterPropertiesByLanguageInJsonSchema,
13+
filterReadOnlyPropertiesInJsonSchema,
14+
filterWritablePropertiesInJsonSchema,
15+
} from "@frontend/common/src/utils";
516
import { Add, Close, Delete, Edit } from "@mui/icons-material";
617
import {
718
Box,
@@ -170,29 +181,26 @@ const MDRendererContainer = styled(Box)(({ theme }) => ({
170181
},
171182
}));
172183

173-
const MDEditorField: Field = ErrorBoundary.with(
174-
{ fallback: Components.ErrorFallback },
175-
({ disabled, formData, name, onChange: rawOnChange }) => {
176-
const [valueState, setValueState] = React.useState<string | undefined>(formData?.toString() || "");
177-
const onChange = (value?: string) => {
178-
setValueState(value);
179-
rawOnChange(value, undefined, name);
180-
};
181-
return (
182-
<MUIStyledFieldset>
183-
<Typography variant="subtitle2" component="legend" children={name} />
184-
<Stack direction="row" spacing={2} sx={{ width: "100%", height: "100%", minHeight: "100%", maxHeight: "100%", flexGrow: 1, py: 2 }}>
185-
<Box sx={{ width: "50%", maxWidth: "50%" }}>
186-
<Components.MarkdownEditor disabled={disabled} name={name} value={valueState} onChange={onChange} extraCommands={[]} />
187-
</Box>
188-
<MDRendererContainer>
189-
<Components.MDXRenderer text={valueState || ""} format="md" />
190-
</MDRendererContainer>
191-
</Stack>
192-
</MUIStyledFieldset>
193-
);
194-
}
195-
);
184+
const MDEditorField: Field = ErrorBoundary.with({ fallback: Components.ErrorFallback }, ({ disabled, formData, name, onChange: rawOnChange }) => {
185+
const [valueState, setValueState] = React.useState<string | undefined>(formData?.toString() || "");
186+
const onChange = (value?: string) => {
187+
setValueState(value);
188+
rawOnChange(value, undefined, name);
189+
};
190+
return (
191+
<MUIStyledFieldset>
192+
<Typography variant="subtitle2" component="legend" children={name} />
193+
<Stack direction="row" spacing={2} sx={{ width: "100%", height: "100%", minHeight: "100%", maxHeight: "100%", flexGrow: 1, py: 2 }}>
194+
<Box sx={{ width: "50%", maxWidth: "50%" }}>
195+
<Components.MarkdownEditor disabled={disabled} name={name} value={valueState} onChange={onChange} extraCommands={[]} />
196+
</Box>
197+
<MDRendererContainer>
198+
<Components.MDXRenderer text={valueState || ""} format="md" />
199+
</MDRendererContainer>
200+
</Stack>
201+
</MUIStyledFieldset>
202+
);
203+
});
196204

197205
type ReadOnlyValueFieldStateType = {
198206
loading: boolean;
@@ -278,8 +286,24 @@ const InnerAdminEditor: React.FC<AppResourceIdType & AdminEditorPropsType> = Err
278286
tab: 0,
279287
formData: undefined,
280288
});
289+
281290
const backendAdminClient = useBackendAdminClient();
282291
const { data: schemaInfo } = useSchemaQuery(backendAdminClient, app, resource);
292+
const { data: choicesData } = useChoicesQuery(backendAdminClient, app, resource);
293+
294+
// Merge choices into schema for FK/M2M fields
295+
React.useMemo(() => {
296+
if (!choicesData || !schemaInfo.schema.properties) return;
297+
for (const [fieldName, items] of Object.entries(choicesData)) {
298+
const prop = (schemaInfo.schema.properties as Record<string, RJSFSchema>)[fieldName];
299+
if (!prop) continue;
300+
if (prop.type === "array" && prop.items) {
301+
(prop.items as RJSFSchema).oneOf = items;
302+
} else {
303+
prop.oneOf = items;
304+
}
305+
}
306+
}, [choicesData, schemaInfo.schema]);
283307

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

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

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { Components } from "@frontend/common";
2-
import { useBackendAdminClient, useListQuery } from "@frontend/common/src/hooks/useAdminAPI";
2+
import { useBackendAdminClient, useChoicesQuery, useListQuery, useOpenApiSchemaQuery } from "@frontend/common/src/hooks/useAdminAPI";
3+
import { extractQueryParameters } from "@frontend/common/src/utils";
34
import { Add } from "@mui/icons-material";
45
import { Box, Button, CircularProgress, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from "@mui/material";
56
import { ErrorBoundary, Suspense } from "@suspensive/react";
67
import * as React from "react";
7-
import { Link, useNavigate } from "react-router-dom";
8+
import { Link, useNavigate, useSearchParams } from "react-router-dom";
89

10+
import { AdminListFilter } from "../elements/admin_list_filter";
911
import { BackendAdminSignInGuard } from "../elements/admin_signin_guard";
1012

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

3349
return (
3450
<Stack sx={{ flexGrow: 1, width: "100%", minHeight: "100%" }}>
3551
<Typography variant="h5">
3652
{app.toUpperCase()} &gt; {resource.toUpperCase()} &gt; 목록
3753
</Typography>
3854
<br />
55+
<AdminListFilter parameters={queryParameters} values={filterParams} choices={choicesQuery.data} onApply={handleFilterApply} />
3956
<Box>
4057
{!hideCreateNew && (
4158
<Button variant="contained" onClick={() => navigate(`/${app}/${resource}/create`)} startIcon={<Add />}>

apps/pyconkr-admin/src/components/pages/account/manage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
import { me } from "@frontend/common/src/apis/admin_api";
12
import { useBackendAdminClient, useChangePasswordMutation, useSignOutMutation } from "@frontend/common/src/hooks/useAdminAPI";
23
import { getFormValue, isFormValid } from "@frontend/common/src/utils";
3-
import { me } from "@frontend/common/src/apis/admin_api";
44
import { Logout } from "@mui/icons-material";
55
import { Button, Stack, Tab, Tabs, TextField, Typography } from "@mui/material";
66
import * as React from "react";

0 commit comments

Comments
 (0)