From 6fd45ef6f8060404503ca32ed2088eb617df0a3b Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 17 May 2026 21:01:21 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20allauth=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=86=8C=EC=85=9C=20=EA=B3=84=EC=A0=95=20=EB=B0=8F=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EC=A3=BC=EC=86=8C=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/pyconkr-admin/src/routes.tsx | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/apps/pyconkr-admin/src/routes.tsx b/apps/pyconkr-admin/src/routes.tsx index 9a6bbb7..38648be 100644 --- a/apps/pyconkr-admin/src/routes.tsx +++ b/apps/pyconkr-admin/src/routes.tsx @@ -1,6 +1,7 @@ import { AccountCircle, AccountTree, + AlternateEmail, Apartment, Article, AutoFixHigh, @@ -13,11 +14,13 @@ import { FolderSpecial, Forum, Handshake, + Login, LocalOffer, ManageAccounts, MarkEmailRead, MeetingRoom, NoteAlt, + Person, Public, ReceiptLong, Send, @@ -316,6 +319,34 @@ export const RouteDefinitions: RouteDef[] = [ app: "external-api/google", resource: "oauth2", }, + { + type: "separator", + key: "allauth-separator", + title: "소셜 계정 관리", + }, + { + type: "autoAdminRouteDefinition", + key: "allauth-social-app", + icon: Login, + title: "소셜 앱", + app: "allauth", + resource: "social-app", + }, + { + type: "routeDefinition", + key: "allauth-social-account", + icon: Person, + title: "소셜 계정", + route: "/allauth/social-account", + }, + { + type: "autoAdminRouteDefinition", + key: "allauth-email-address", + icon: AlternateEmail, + title: "이메일 주소", + app: "allauth", + resource: "email-address", + }, { type: "routeDefinition", key: "user-account", @@ -364,6 +395,10 @@ export const RegisteredRoutes = { "/file/publicfile/:id": , "/user/userext": , "/user/userext/:id": , + "/allauth/social-app": , + "/allauth/social-account": , + "/allauth/social-account/:id": , + "/allauth/email-address": , "/account": , "/account/sign-in": , "/account/manage": , From 70edea0c72c9d73a53c6e76321f84922385225f7 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 17 May 2026 22:04:01 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EC=A3=BC=EC=86=8C=20=EB=B0=8F=20=EC=86=8C?= =?UTF-8?q?=EC=85=9C=20=EA=B3=84=EC=A0=95=20=EA=B4=80=EB=A6=AC=20=EC=84=B9?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/pages/user/editor.tsx | 25 ++- .../pages/user/email_address_section.tsx | 155 ++++++++++++++++++ .../pages/user/social_account_section.tsx | 100 +++++++++++ 3 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 apps/pyconkr-admin/src/components/pages/user/email_address_section.tsx create mode 100644 apps/pyconkr-admin/src/components/pages/user/social_account_section.tsx diff --git a/apps/pyconkr-admin/src/components/pages/user/editor.tsx b/apps/pyconkr-admin/src/components/pages/user/editor.tsx index 493a84d..7d78028 100644 --- a/apps/pyconkr-admin/src/components/pages/user/editor.tsx +++ b/apps/pyconkr-admin/src/components/pages/user/editor.tsx @@ -9,8 +9,10 @@ import { ErrorFallback } from "@apps/pyconkr-admin/components/elements/error_fal import { AdminEditor } from "@apps/pyconkr-admin/components/layouts/admin_editor"; import { addErrorSnackbar } from "@apps/pyconkr-admin/utils/snackbar"; +import { EmailAddressSection } from "./email_address_section"; import { PasswordResultDialog } from "./password_result_dialog"; import { ShopOrderSection } from "./shop_order_section"; +import { SocialAccountSection } from "./social_account_section"; type PageStateType = { isConfirmDialogOpen: boolean; @@ -75,6 +77,11 @@ export const AdminUserExtEditor: FC = ErrorBoundary.with( onClick: () => id && openConfirmDialog(), }; + const stripNestedFromSubmit = (data: Record) => { + delete data.email_addresses; + delete data.social_accounts; + }; + return ( <> @@ -92,8 +99,22 @@ export const AdminUserExtEditor: FC = ErrorBoundary.with( - - {id && } + + {id && ( + <> + + + + + )}
diff --git a/apps/pyconkr-admin/src/components/pages/user/email_address_section.tsx b/apps/pyconkr-admin/src/components/pages/user/email_address_section.tsx new file mode 100644 index 0000000..a1913dc --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/user/email_address_section.tsx @@ -0,0 +1,155 @@ +import { useBackendAdminClient, useCreateMutation, useListQuery, useRemovePreparedMutation } from "@frontend/common/hooks/useAdminAPI"; +import { Add, Delete } from "@mui/icons-material"; +import { + Box, + Button, + Checkbox, + Chip, + CircularProgress, + Divider, + FormControlLabel, + IconButton, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import { ErrorBoundary, Suspense } from "@suspensive/react"; +import { FC, FormEvent, useState } from "react"; +import { Link } from "react-router-dom"; + +import { ErrorFallback } from "@apps/pyconkr-admin/components/elements/error_fallback"; +import { addErrorSnackbar, addSnackbar } from "@apps/pyconkr-admin/utils/snackbar"; + +type EmailAddressRow = { + id: number; + user: string; + email: string; + verified: boolean; + primary: boolean; + str_repr: string; +}; + +type EmailAddressCreateRequest = { + id?: number; + user: string; + email: string; + verified: boolean; + primary: boolean; +}; + +const InnerEmailAddressSection: FC<{ userId: string }> = ErrorBoundary.with( + { fallback: ErrorFallback }, + Suspense.with({ fallback: }, ({ userId }) => { + const client = useBackendAdminClient(); + const listQuery = useListQuery(client, "allauth", "email-address", { user: userId }); + const items = listQuery.data ?? []; + + const [newEmail, setNewEmail] = useState(""); + const [newVerified, setNewVerified] = useState(false); + const [newPrimary, setNewPrimary] = useState(false); + + const createMutation = useCreateMutation(client, "allauth", "email-address"); + const removeMutation = useRemovePreparedMutation(client, "allauth", "email-address"); + + const handleCreate = (e: FormEvent) => { + e.preventDefault(); + const email = newEmail.trim(); + if (!email) return; + createMutation.mutate( + { user: userId, email, verified: newVerified, primary: newPrimary }, + { + onSuccess: () => { + addSnackbar("이메일 주소를 추가했습니다.", "success"); + setNewEmail(""); + setNewVerified(false); + setNewPrimary(false); + }, + onError: addErrorSnackbar, + } + ); + }; + + const handleDelete = (id: number, email: string) => { + if (!window.confirm(`'${email}'을(를) 삭제하시겠습니까?`)) return; + removeMutation.mutate(String(id), { + onSuccess: () => addSnackbar("이메일 주소를 삭제했습니다.", "success"), + onError: addErrorSnackbar, + }); + }; + + return ( + + + 이메일 주소 ({items.length}) + + + + 이메일 + + verified + + + primary + + + 작업 + + + + + {items.length === 0 && ( + + + 등록된 이메일 주소가 없습니다. + + + )} + {items.map((ea) => ( + + + {ea.email} + + {ea.verified ? : "—"} + {ea.primary ? : "—"} + + handleDelete(ea.id, ea.email)} + disabled={removeMutation.isPending} + aria-label="삭제" + > + + + + + ))} + +
+ + setNewEmail(e.target.value)} + required + size="small" + sx={{ minWidth: 280 }} + /> + setNewVerified(e.target.checked)} />} label="verified" /> + setNewPrimary(e.target.checked)} />} label="primary" /> + + +
+ ); + }) +); + +export const EmailAddressSection: FC<{ userId: string }> = (props) => ; diff --git a/apps/pyconkr-admin/src/components/pages/user/social_account_section.tsx b/apps/pyconkr-admin/src/components/pages/user/social_account_section.tsx new file mode 100644 index 0000000..2c28e5a --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/user/social_account_section.tsx @@ -0,0 +1,100 @@ +import { useBackendAdminClient, useListQuery, useRemovePreparedMutation } from "@frontend/common/hooks/useAdminAPI"; +import { Delete } from "@mui/icons-material"; +import { Alert, CircularProgress, Divider, IconButton, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from "@mui/material"; +import { ErrorBoundary, Suspense } from "@suspensive/react"; +import { FC } from "react"; +import { Link } from "react-router-dom"; + +import { ErrorFallback } from "@apps/pyconkr-admin/components/elements/error_fallback"; +import { addErrorSnackbar, addSnackbar } from "@apps/pyconkr-admin/utils/snackbar"; + +type SocialAccountRow = { + id: number; + user: string; + provider: string; + uid: string; + last_login: string | null; + date_joined: string; + extra_data: Record; + str_repr: string; +}; + +const InnerSocialAccountSection: FC<{ userId: string }> = ErrorBoundary.with( + { fallback: ErrorFallback }, + Suspense.with({ fallback: }, ({ userId }) => { + const client = useBackendAdminClient(); + const listQuery = useListQuery(client, "allauth", "social-account", { user: userId }); + const items = listQuery.data ?? []; + const removeMutation = useRemovePreparedMutation(client, "allauth", "social-account"); + + const handleDelete = (id: number, label: string) => { + if ( + !window.confirm( + `'${label}'을(를) 삭제하시겠습니까?\n\n이 사용자에게 다른 소셜 계정이 남아있지 않다면, 연결된 모든 이메일 주소도 함께 삭제됩니다.` + ) + ) + return; + removeMutation.mutate(String(id), { + onSuccess: () => addSnackbar("소셜 계정을 삭제했습니다.", "success"), + onError: addErrorSnackbar, + }); + }; + + return ( + + + 소셜 계정 ({items.length}) + + 소셜 계정은 OAuth 로그인 시 자동으로 생성됩니다. 어드민에서는 삭제만 가능합니다. + + + + + Provider + UID + 최근 로그인 + 가입일 + + 작업 + + + + + {items.length === 0 && ( + + + 연결된 소셜 계정이 없습니다. + + + )} + {items.map((sa) => ( + + {sa.provider} + + + {sa.uid} + + + {sa.last_login ? new Date(sa.last_login).toLocaleString() : "—"} + {new Date(sa.date_joined).toLocaleString()} + + handleDelete(sa.id, sa.str_repr || `${sa.provider}:${sa.uid}`)} + disabled={removeMutation.isPending} + aria-label="삭제" + > + + + + + ))} + +
+
+ ); + }) +); + +export const SocialAccountSection: FC<{ userId: string }> = (props) => ; From 1d0a78c691fe77b5bc79e0b362a90a73a0dc74e2 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 17 May 2026 23:22:22 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=97=90=EB=94=94=ED=84=B0/=EB=A6=AC=EC=8A=A4=ED=8A=B8=20FK=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20=E2=80=94=20Autocompl?= =?UTF-8?q?ete=20=EC=9C=84=EC=A0=AF=20=EB=8F=84=EC=9E=85,=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=C2=B7=ED=95=84=EB=93=9C=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EC=97=B0=EA=B2=B0=20prop=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/elements/admin_list_filter.tsx | 27 ++++--- .../elements/autocomplete_select_widget.tsx | 45 ++++++++++++ .../src/components/layouts/admin_editor.tsx | 72 +++++++++++++++---- .../src/components/layouts/admin_list.tsx | 29 +++++++- apps/pyconkr-admin/src/routes.tsx | 15 +++- packages/common/src/hooks/useAdminAPI.ts | 10 ++- 6 files changed, 164 insertions(+), 34 deletions(-) create mode 100644 apps/pyconkr-admin/src/components/elements/autocomplete_select_widget.tsx diff --git a/apps/pyconkr-admin/src/components/elements/admin_list_filter.tsx b/apps/pyconkr-admin/src/components/elements/admin_list_filter.tsx index e24ad6c..a2b7a29 100644 --- a/apps/pyconkr-admin/src/components/elements/admin_list_filter.tsx +++ b/apps/pyconkr-admin/src/components/elements/admin_list_filter.tsx @@ -1,6 +1,6 @@ import { ChoicesResponse, OpenAPIParameterSchema } from "@frontend/common/schemas/backendAdminAPI"; import { Add, Clear, FilterList, RestartAlt } from "@mui/icons-material"; -import { Box, Button, Chip, FormControl, IconButton, InputLabel, MenuItem, Select, Stack, TextField } from "@mui/material"; +import { Autocomplete, Box, Button, Chip, FormControl, IconButton, InputLabel, MenuItem, Select, Stack, TextField } from "@mui/material"; import { FC, useEffect, useState } from "react"; type AdminListFilterProps = { parameters: OpenAPIParameterSchema[]; @@ -79,20 +79,19 @@ const FilterField: FC = ({ param, value, choices, onChange }) if (schema?.enum) return ; if (choices && choices.length > 0) { + const options = choices.map((c) => ({ value: c.const ?? "", label: c.title })); + const currentOption = options.find((opt) => opt.value === value) ?? null; return ( - - {name} - - + onChange(name, newOption?.value ?? "")} + getOptionLabel={(opt) => opt.label || String(opt.value)} + isOptionEqualToValue={(opt, val) => opt.value === val.value} + renderInput={(params) => } + /> ); } diff --git a/apps/pyconkr-admin/src/components/elements/autocomplete_select_widget.tsx b/apps/pyconkr-admin/src/components/elements/autocomplete_select_widget.tsx new file mode 100644 index 0000000..a2f7347 --- /dev/null +++ b/apps/pyconkr-admin/src/components/elements/autocomplete_select_widget.tsx @@ -0,0 +1,45 @@ +import { Autocomplete, TextField } from "@mui/material"; +import { EnumOptionsType, WidgetProps } from "@rjsf/utils"; +import { FC, useMemo } from "react"; + +type FormContextWithChoices = { + choicesData?: Record; +}; + +export const AutocompleteSelectWidget: FC = (props) => { + const { id, value, label, schema, required, disabled, readonly, autofocus, placeholder, options, onChange, onBlur, onFocus, formContext } = props; + + // RJSF strips uiSchema enumOptions when the schema has no enum/oneOf, so we read choices from + // formContext and rebuild enumOptions here. fieldName is derived from the RJSF id (root_). + const enumOptions = useMemo(() => { + const fromRjsf = options.enumOptions as EnumOptionsType[] | undefined; + if (fromRjsf && fromRjsf.length > 0) return fromRjsf; + const fieldName = id.replace(/^root_/, ""); + const items = (formContext as FormContextWithChoices | undefined)?.choicesData?.[fieldName]; + if (!items) return []; + const coerceToNumber = schema.type === "integer" || schema.type === "number"; + return items.map((i) => ({ value: coerceToNumber ? Number(i.const) : i.const, label: i.title || String(i.const) })); + }, [options.enumOptions, formContext, id, schema.type]); + + const currentOption = enumOptions.find((opt) => opt.value === value) ?? null; + + return ( + opt.label || String(opt.value)} + isOptionEqualToValue={(opt, val) => opt.value === val.value} + onChange={(_, newOption) => onChange(newOption?.value ?? undefined)} + onBlur={() => onBlur(id, value)} + onFocus={() => onFocus(id, value)} + disabled={disabled || readonly} + disableClearable={required} + autoHighlight + fullWidth + renderInput={(params) => ( + + )} + /> + ); +}; diff --git a/apps/pyconkr-admin/src/components/layouts/admin_editor.tsx b/apps/pyconkr-admin/src/components/layouts/admin_editor.tsx index d125136..28497e7 100644 --- a/apps/pyconkr-admin/src/components/layouts/admin_editor.tsx +++ b/apps/pyconkr-admin/src/components/layouts/admin_editor.tsx @@ -58,10 +58,11 @@ import { useRef, useState, } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { Link, useNavigate, useParams } from "react-router-dom"; import { addProp, isArray, isNonNullish, isObjectType, isString } from "remeda"; import { BackendAdminSignInGuard } from "@apps/pyconkr-admin/components/elements/admin_signin_guard"; +import { AutocompleteSelectWidget } from "@apps/pyconkr-admin/components/elements/autocomplete_select_widget"; import { ErrorFallback } from "@apps/pyconkr-admin/components/elements/error_fallback"; import { addErrorSnackbar, addSnackbar } from "@apps/pyconkr-admin/utils/snackbar"; @@ -70,6 +71,10 @@ type onSubmitType = (data: Record, event: FormEvent) => type AppResourceType = { app: string; resource: string }; type AppResourceIdType = AppResourceType & { id?: string }; +export type FieldLinkTarget = { + app: string; + resource: string; +}; type AdminEditorPropsType = PropsWithChildren<{ hidingFields?: string[]; context?: Record; @@ -80,6 +85,12 @@ type AdminEditorPropsType = PropsWithChildren<{ notModifiable?: boolean; notDeletable?: boolean; extraActions?: ButtonProps[]; + /** + * For each field, render an "open in new tab" link next to the value pointing at the editor route + * for that field's referenced object. Currently applies to the read-only field table only. + * The field's current value is used as the target id. + */ + fieldLinks?: Record; }>; const processFile = (event: ChangeEvent) => { @@ -268,7 +279,18 @@ const ReadOnlyValueField: FC<{ ); } - return value as string; + if (value === null || value === undefined) return ""; + if (typeof value === "object") { + return ( + + {JSON.stringify(value, null, 2)} + + ); + } + return String(value); }); type InnerAdminEditorStateType = { @@ -293,6 +315,7 @@ const InnerAdminEditor: FC = ErrorBoun extraActions, notModifiable, notDeletable, + fieldLinks, children, }) => { const navigate = useNavigate(); @@ -306,7 +329,10 @@ const InnerAdminEditor: FC = ErrorBoun const { data: schemaInfo } = useSchemaQuery(backendAdminClient, app, resource); const { data: choicesData } = useChoicesQuery(backendAdminClient, app, resource); - // Merge choices into schema for FK/M2M fields + // Merge choices into schema ONLY for M2M (array) fields — M2MSelect reads from schema.items.oneOf. + // Single-value FK choices are NOT merged here because AJV blows up when compiling a oneOf with + // thousands of const entries (e.g. user FK on EmailAddress). Those choices are attached to + // uiSchema below as enumOptions and rendered by AutocompleteSelectWidget instead. useMemo(() => { if (!choicesData || !schemaInfo.schema.properties) return; for (const [fieldName, items] of Object.entries(choicesData)) { @@ -314,8 +340,6 @@ const InnerAdminEditor: FC = ErrorBoun if (!prop) continue; if (prop.type === "array" && prop.items) { (prop.items as RJSFSchema).oneOf = items; - } else { - prop.oneOf = items; } } }, [choicesData, schemaInfo.schema]); @@ -396,7 +420,20 @@ const InnerAdminEditor: FC = ErrorBoun schemaInfo.translation_fields, selectedLanguage ); - const uiSchema: UiSchema = schemaInfo.ui_schema; + const baseUiSchema: UiSchema = schemaInfo.ui_schema; + // Force AutocompleteSelectWidget on single-value FKs that have choices. Choices themselves are + // passed through formContext (see below) rather than uiSchema, because RJSF overwrites + // ui:options.enumOptions with [] when the schema has no enum/oneOf. + const uiSchema: UiSchema = useMemo(() => { + if (!choicesData) return baseUiSchema; + const enriched: UiSchema = { ...baseUiSchema }; + for (const fieldName of Object.keys(choicesData)) { + const prop = (schemaInfo.schema.properties as Record | undefined)?.[fieldName]; + if (!prop || prop.type === "array") continue; + enriched[fieldName] = { ...(enriched[fieldName] ?? {}), "ui:widget": "autocomplete_select" }; + } + return enriched; + }, [choicesData, schemaInfo.schema, baseUiSchema]); const disabled = createMutation.isPending || modifyMutation.isPending || deleteMutation.isPending; const title = `${app.toUpperCase()} > ${resource.toUpperCase()} > ${id ? "편집: " + id : "새 객체 추가"}`; @@ -448,14 +485,18 @@ const InnerAdminEditor: FC = ErrorBoun - {Object.keys(readOnlySchema.properties || {}).map((key) => ( - - {key} - - - - - ))} + {Object.keys(readOnlySchema.properties || {}).map((key) => { + const link = fieldLinks?.[key]; + const value = languageFilteredFormData?.[key]; + const showLink = link && value !== null && value !== undefined && value !== ""; + const field = ; + return ( + + {key} + {showLink ? {field} : field} + + ); + })}
@@ -472,12 +513,13 @@ const InnerAdminEditor: FC = ErrorBoun formData={languageFilteredFormData} liveValidate focusOnFirstError - formContext={{ readonlyAsDisabled: true }} + formContext={{ readonlyAsDisabled: true, choicesData }} onChange={({ formData }) => appendFormDataState(formData)} onSubmit={onSubmitFunc} disabled={disabled} showErrorList={false} fields={{ file: FileField, m2m_select: M2MSelect, markdown: MDEditorField }} + widgets={{ SelectWidget: AutocompleteSelectWidget, autocomplete_select: AutocompleteSelectWidget }} /> diff --git a/apps/pyconkr-admin/src/components/layouts/admin_list.tsx b/apps/pyconkr-admin/src/components/layouts/admin_list.tsx index fed1b82..5b26e76 100644 --- a/apps/pyconkr-admin/src/components/layouts/admin_list.tsx +++ b/apps/pyconkr-admin/src/components/layouts/admin_list.tsx @@ -1,10 +1,12 @@ import { useBackendAdminClient, + useChoicesQueries, useChoicesQuery, useListQuery, useOpenApiSchemaQuery, useRemovePreparedMutation, } from "@frontend/common/hooks/useAdminAPI"; +import { ChoicesResponse } from "@frontend/common/schemas/backendAdminAPI"; import { extractQueryParameters } from "@frontend/common/utils"; import { Add, Delete, Edit } from "@mui/icons-material"; import { Box, Button, CircularProgress, IconButton, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from "@mui/material"; @@ -32,6 +34,13 @@ export type AdminListColumn = { render?: (row: Record) => ReactNode; }; +export type FilterChoicesSource = { + app: string; + resource: string; + /** Field name in the source resource's choices response. Defaults to the local field name (the map key). */ + field?: string; +}; + type AdminListProps = { app: string; resource: string; @@ -41,13 +50,14 @@ type AdminListProps = { hideCreateNew?: boolean; columns?: AdminListColumn[]; enableRowActions?: boolean; + filterChoicesFrom?: Record; }; const InnerAdminList: FC = ErrorBoundary.with( { fallback: ErrorFallback }, Suspense.with( { fallback: }, - ({ app, resource, title, hideCreatedAt, hideUpdatedAt, hideCreateNew, columns, enableRowActions }) => { + ({ app, resource, title, hideCreatedAt, hideUpdatedAt, hideCreateNew, columns, enableRowActions, filterChoicesFrom }) => { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); @@ -61,6 +71,21 @@ const InnerAdminList: FC = ErrorBoundary.with( const choicesQuery = useChoicesQuery(backendAdminClient, app, resource); + const overrideEntries = useMemo(() => Object.entries(filterChoicesFrom ?? {}), [filterChoicesFrom]); + const overrideQueries = useChoicesQueries( + backendAdminClient, + overrideEntries.map(([, src]) => ({ app: src.app, resource: src.resource })) + ); + const mergedChoices = useMemo(() => { + const merged: ChoicesResponse = { ...(choicesQuery.data ?? {}) }; + overrideEntries.forEach(([localField, src], i) => { + const sourceField = src.field ?? localField; + const sourceChoices = overrideQueries[i]?.data?.[sourceField]; + if (sourceChoices) merged[localField] = sourceChoices; + }); + return merged; + }, [choicesQuery.data, overrideEntries, overrideQueries]); + const removeMutation = useRemovePreparedMutation(backendAdminClient, app, resource); const handleFilterApply = (newParams: Record) => setSearchParams(newParams, { replace: true }); @@ -89,7 +114,7 @@ const InnerAdminList: FC = ErrorBoundary.with( {title ?? `${app.toUpperCase()} > ${resource.toUpperCase()} > 목록`}
- + {!hideCreateNew && (