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
218 changes: 218 additions & 0 deletions apps/pyconkr-admin/src/components/elements/admin_list_filter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { Add, Clear, FilterList, RestartAlt } from "@mui/icons-material";
import { Box, Button, Chip, FormControl, IconButton, InputLabel, MenuItem, Select, Stack, TextField } from "@mui/material";
import * as React from "react";

import BackendAdminAPISchemas from "../../../../../packages/common/src/schemas/backendAdminAPI";

type OpenAPIParameterSchema = BackendAdminAPISchemas.OpenAPIParameterSchema;
type ChoicesResponse = BackendAdminAPISchemas.ChoicesResponse;

type AdminListFilterProps = {
parameters: OpenAPIParameterSchema[];
values: Record<string, string>;
choices?: ChoicesResponse;
onApply: (values: Record<string, string>) => void;
};

export const AdminListFilter: React.FC<AdminListFilterProps> = ({ parameters, values, choices, onApply }) => {
const [localValues, setLocalValues] = React.useState<Record<string, string>>(values);

React.useEffect(() => {
setLocalValues(values);
}, [values]);

const handleChange = (name: string, value: string) => {
setLocalValues((prev) => ({ ...prev, [name]: value }));
};

const handleApply = () => {
const cleaned = Object.fromEntries(Object.entries(localValues).filter(([, v]) => v !== ""));
onApply(cleaned);
};

const handleClear = () => {
setLocalValues({});
onApply({});
};

if (parameters.length === 0) return null;

return (
<Box sx={{ mb: 2 }}>
<Stack spacing={2} sx={{ p: 2, border: 1, borderColor: "divider", borderRadius: 1 }}>
<Stack direction="row" alignItems="center" spacing={1}>
<FilterList fontSize="small" />
<span>필터</span>
</Stack>
<Stack direction="row" spacing={2} sx={{ flexWrap: "wrap", alignItems: "flex-start" }}>
{parameters.map((param) => (
<FilterField
key={param.name}
param={param}
value={localValues[param.name] || ""}
choices={choices?.[param.name]}
onChange={handleChange}
/>
))}
</Stack>
<Stack direction="row" spacing={1}>
<Button variant="outlined" onClick={handleApply} size="small">
적용
</Button>
<Button variant="text" onClick={handleClear} size="small" startIcon={<RestartAlt />}>
초기화
</Button>
</Stack>
</Stack>
</Box>
);
};

type ChoiceItem = { const: string | null; title: string };

type FilterFieldProps = {
param: OpenAPIParameterSchema;
value: string;
choices?: ChoiceItem[];
onChange: (name: string, value: string) => void;
};

const FilterField: React.FC<FilterFieldProps> = ({ param, value, choices, onChange }) => {
const { name, schema, description } = param;

if (schema?.type === "array") return <ArrayFilterField name={name} items={schema.items} value={value} onChange={onChange} />;
if (schema?.enum) return <EnumFilterField name={name} options={schema.enum} value={value} onChange={onChange} />;

if (choices && choices.length > 0) {
return (
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>{name}</InputLabel>
<Select value={value} label={name} onChange={(e) => onChange(name, e.target.value as string)}>
<MenuItem value="">
<em>전체</em>
</MenuItem>
{choices.map((choice) => (
<MenuItem key={choice.const ?? "__null__"} value={choice.const ?? ""}>
{choice.title}
</MenuItem>
))}
</Select>
</FormControl>
);
}

const inputType = schema?.type === "integer" || schema?.type === "number" ? "number" : "text";
const helperText = schema?.format === "uuid" ? "UUID" : description || undefined;

return (
<TextField
label={name}
value={value}
onChange={(e) => onChange(name, e.target.value)}
size="small"
type={inputType}
helperText={helperText}
sx={{ minWidth: 200 }}
/>
);
};

type EnumFilterFieldProps = {
name: string;
options: string[];
value: string;
onChange: (name: string, value: string) => void;
};

const EnumFilterField: React.FC<EnumFilterFieldProps> = ({ name, options, value, onChange }) => {
const selectedValues = value ? value.split(",") : [];

const handleChange = (newValues: string | string[]) => {
const arr = typeof newValues === "string" ? newValues.split(",") : newValues;
onChange(name, arr.filter((v) => v !== "").join(","));
};

return (
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>{name}</InputLabel>
<Select
multiple
value={selectedValues}
label={name}
onChange={(e) => handleChange(e.target.value)}
renderValue={(selected) => (
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
{selected.map((v) => (
<Chip key={v} label={v} size="small" />
))}
</Box>
)}
>
{options.map((opt) => (
<MenuItem key={opt} value={opt}>
{opt}
</MenuItem>
))}
</Select>
</FormControl>
);
};

type ArrayFilterFieldProps = {
name: string;
items?: { type?: string; enum?: string[] };
value: string;
onChange: (name: string, value: string) => void;
};

const ArrayFilterField: React.FC<ArrayFilterFieldProps> = ({ name, items, value, onChange }) => {
const values = value ? value.split(",") : [];

const updateValues = (newValues: string[]) => onChange(name, newValues.filter((v) => v !== "").join(","));
const handleAdd = () => updateValues([...values, ""]);
const handleRemove = (index: number) => updateValues(values.filter((_, i) => i !== index));

const handleItemChange = (index: number, newValue: string) => {
const newValues = [...values];
newValues[index] = newValue;
updateValues(newValues);
};

const inputType = items?.type === "integer" || items?.type === "number" ? "number" : "text";

return (
<Box sx={{ minWidth: 200 }}>
<Stack spacing={1}>
<Stack direction="row" alignItems="center" spacing={1}>
<InputLabel sx={{ fontSize: "0.875rem" }}>{name}</InputLabel>
<IconButton size="small" onClick={handleAdd}>
<Add fontSize="small" />
</IconButton>
</Stack>
{values.map((v, index) => (
<Stack key={index} direction="row" spacing={0.5} alignItems="center">
{items?.enum ? (
<FormControl size="small" sx={{ minWidth: 150 }}>
<Select value={v} onChange={(e) => handleItemChange(index, e.target.value as string)} displayEmpty>
<MenuItem value="">
<em>선택</em>
</MenuItem>
{items.enum.map((opt) => (
<MenuItem key={opt} value={opt}>
{opt}
</MenuItem>
))}
</Select>
</FormControl>
) : (
<TextField value={v} onChange={(e) => handleItemChange(index, e.target.value)} size="small" type={inputType} sx={{ minWidth: 150 }} />
)}
<IconButton size="small" onClick={() => handleRemove(index)}>
<Clear fontSize="small" />
</IconButton>
</Stack>
))}
</Stack>
</Box>
);
};
15 changes: 15 additions & 0 deletions apps/pyconkr-admin/src/components/layouts/admin_editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,21 @@ const InnerAdminEditor: React.FC<AppResourceIdType & AdminEditorPropsType> = Err
});
const backendAdminClient = Common.Hooks.BackendAdminAPI.useBackendAdminClient();
const { data: schemaInfo } = Common.Hooks.BackendAdminAPI.useSchemaQuery(backendAdminClient, app, resource);
const { data: choicesData } = Common.Hooks.BackendAdminAPI.useChoicesQuery(backendAdminClient, app, resource);

// Merge choices into schema for FK/M2M fields
React.useMemo(() => {
if (!choicesData || !schemaInfo.schema.properties) return;
for (const [fieldName, items] of Object.entries(choicesData)) {
const prop = (schemaInfo.schema.properties as Record<string, RJSFSchema>)[fieldName];
if (!prop) continue;
if (prop.type === "array" && prop.items) {
(prop.items as RJSFSchema).oneOf = items;
} else {
prop.oneOf = items;
}
}
}, [choicesData, schemaInfo.schema]);

const setTab = (_: React.SyntheticEvent, tab: number) => setEditorState((ps) => ({ ...ps, tab }));
const setFormData = (formData?: Record<string, string>) => setEditorState((ps) => ({ ...ps, formData }));
Expand Down
19 changes: 17 additions & 2 deletions apps/pyconkr-admin/src/components/layouts/admin_list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { Add } from "@mui/icons-material";
import { Box, Button, CircularProgress, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from "@mui/material";
import { ErrorBoundary, Suspense } from "@suspensive/react";
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
import { Link, useNavigate, useSearchParams } from "react-router-dom";

import { AdminListFilter } from "../elements/admin_list_filter";
import { BackendAdminSignInGuard } from "../elements/admin_signin_guard";

type AdminListProps = {
Expand All @@ -26,15 +27,29 @@ const InnerAdminList: React.FC<AdminListProps> = ErrorBoundary.with(
{ fallback: Common.Components.ErrorFallback },
Suspense.with({ fallback: <CircularProgress /> }, ({ app, resource, hideCreatedAt, hideUpdatedAt, hideCreateNew }) => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const backendAdminClient = Common.Hooks.BackendAdminAPI.useBackendAdminClient();
const listQuery = Common.Hooks.BackendAdminAPI.useListQuery<ListRowType>(backendAdminClient, app, resource);

const filterParams: Record<string, string> = Object.fromEntries(searchParams.entries());
const listQuery = Common.Hooks.BackendAdminAPI.useListQuery<ListRowType>(backendAdminClient, app, resource, filterParams);

const openApiSchemaQuery = Common.Hooks.BackendAdminAPI.useOpenApiSchemaQuery(backendAdminClient);
const queryParameters = React.useMemo(
() => Common.Utils.extractQueryParameters(openApiSchemaQuery.data, app, resource),
[openApiSchemaQuery.data, app, resource]
);

const choicesQuery = Common.Hooks.BackendAdminAPI.useChoicesQuery(backendAdminClient, app, resource);

const handleFilterApply = (newParams: Record<string, string>) => setSearchParams(newParams, { replace: true });

return (
<Stack sx={{ flexGrow: 1, width: "100%", minHeight: "100%" }}>
<Typography variant="h5">
{app.toUpperCase()} &gt; {resource.toUpperCase()} &gt; 목록
</Typography>
<br />
<AdminListFilter parameters={queryParameters} values={filterParams} choices={choicesQuery.data} onApply={handleFilterApply} />
<Box>
{!hideCreateNew && (
<Button variant="contained" onClick={() => navigate(`/${app}/${resource}/create`)} startIcon={<Add />}>
Expand Down
6 changes: 6 additions & 0 deletions packages/common/src/apis/admin_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ namespace BackendAdminAPIs {
export const schema = (client: BackendAPIClient, app: string, resource: string) => () =>
client.get<BackendAdminAPISchemas.AdminSchemaDefinition>(`v1/admin-api/${app}/${resource}/json-schema/`);

export const choices = (client: BackendAPIClient, app: string, resource: string) => () =>
client.get<BackendAdminAPISchemas.ChoicesResponse>(`v1/admin-api/${app}/${resource}/choices/`);

export const openApiSchema = (client: BackendAPIClient) => () =>
client.get<BackendAdminAPISchemas.OpenAPISchema>("api/schema/v1/", { params: { format: "json" } });

export const uploadPublicFile = (client: BackendAPIClient) => (file: File) => {
const formData = new FormData();
formData.append("file", file);
Expand Down
15 changes: 15 additions & 0 deletions packages/common/src/hooks/useAdminAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const QUERY_KEYS = {
ADMIN_LIST: ["query", "admin", "list"],
ADMIN_RETRIEVE: ["query", "admin", "retrieve"],
ADMIN_SCHEMA: ["query", "admin", "schema"],
ADMIN_CHOICES: ["query", "admin", "choices"],
ADMIN_OPENAPI_SCHEMA: ["query", "admin", "openapi-schema"],
ADMIN_PREVIEW_MODIFICATION_AUDIT: ["query", "admin", "retrieve", "modification-audit"],
};

Expand Down Expand Up @@ -67,6 +69,19 @@ namespace BackendAdminAPIHooks {
queryFn: BackendAdminAPIs.schema(client, app, resource),
});

export const useChoicesQuery = (client: BackendAPIClient, app: string, resource: string) =>
useSuspenseQuery({
queryKey: [...QUERY_KEYS.ADMIN_CHOICES, app, resource],
queryFn: BackendAdminAPIs.choices(client, app, resource),
});

export const useOpenApiSchemaQuery = (client: BackendAPIClient) =>
useSuspenseQuery({
queryKey: QUERY_KEYS.ADMIN_OPENAPI_SCHEMA,
queryFn: BackendAdminAPIs.openApiSchema(client),
staleTime: Infinity,
});

export const useListQuery = <T>(client: BackendAPIClient, app: string, resource: string, params?: Record<string, string>) =>
useSuspenseQuery({
queryKey: [...QUERY_KEYS.ADMIN_LIST, app, resource, JSON.stringify(params)],
Expand Down
17 changes: 17 additions & 0 deletions packages/common/src/schemas/backendAdminAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ namespace BackendAdminAPISchemas {
translation_fields: string[];
};

export type ChoicesResponse = Record<string, { const: string | null; title: string }[]>;

export type UserSchema = {
id: number;
username: string;
Expand Down Expand Up @@ -133,6 +135,21 @@ namespace BackendAdminAPISchemas {
original: T;
modified: T;
};

export type OpenAPIParameterSchema = {
name: string;
in: "query" | "path" | "header" | "cookie";
required?: boolean;
description?: string;
schema?: {
type?: string;
format?: string;
items?: { type?: string; enum?: string[] };
enum?: string[];
};
};

export type OpenAPISchema = { paths: Record<string, { get?: { parameters?: OpenAPIParameterSchema[] } }> };
}

export default BackendAdminAPISchemas;
2 changes: 2 additions & 0 deletions packages/common/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
filterReadOnlyPropertiesInJsonSchema as _filterReadOnlyPropertiesInJsonSchema,
filterWritablePropertiesInJsonSchema as _filterWritablePropertiesInJsonSchema,
} from "./json_schema";
import { extractQueryParameters as _extractQueryParameters } from "./openapi";
import { isFilledString as _isFilledString, isValidHttpUrl as _isValidHttpUrl, rtrim as _rtrim } from "./string";

namespace Utils {
Expand All @@ -21,6 +22,7 @@ namespace Utils {
export const filterWritablePropertiesInJsonSchema = _filterWritablePropertiesInJsonSchema;
export const filterReadOnlyPropertiesInJsonSchema = _filterReadOnlyPropertiesInJsonSchema;
export const filterPropertiesByLanguageInJsonSchema = _filterPropertiesByLanguageInJsonSchema;
export const extractQueryParameters = _extractQueryParameters;
}

export default Utils;
12 changes: 12 additions & 0 deletions packages/common/src/utils/openapi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import BackendAdminAPISchemas from "../schemas/backendAdminAPI";

export const extractQueryParameters = (
schema: BackendAdminAPISchemas.OpenAPISchema,
app: string,
resource: string
): BackendAdminAPISchemas.OpenAPIParameterSchema[] => {
const pathItem = schema.paths[`/v1/admin-api/${app}/${resource}/`];
if (!pathItem?.get?.parameters) return [];

return pathItem.get.parameters.filter((param) => param.in === "query");
};